diff --git a/.github/workflows/fips.yml b/.github/workflows/fips.yml new file mode 100644 index 00000000..c4ac12dc --- /dev/null +++ b/.github/workflows/fips.yml @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The suite on Erlang/OTP in FIPS mode, against the validated OpenSSL FIPS provider. +# +# What runs: OTP 28.1.1 (the pinned line) built with --enable-fips, over OpenSSL 3.5.8's +# libcrypto with the FIPS provider built from OpenSSL 3.1.2, the source CMVP certificate #4985 +# (FIPS 140-3) validates, following its security policy (section 11.1: `enable-fips`, +# `make install_fips`, then `openssl fipsinstall` on the machine that runs it). Pairing a newer +# libcrypto with the validated 3.1.2 provider is what OpenSSL's README-FIPS describes and its +# own provider-compatibility CI exercises nightly. Every download is checked against its +# published SHA-256 before it is built. +# +# The order is fixed: deps and compile with FIPS mode OFF (Hex fetches over TLS, rebar3 builds +# telemetry), then the suite with `-crypto fips_mode true`, asserted inside the same VM before +# a test runs. The assertion loads `crypto` in its OWN `-e`: expanding a remote call to +# `:crypto` loads the module, so a check written in the same expression as the load reads a +# crypto loaded before `fips_mode` applied and passes with FIPS off (measured 2026-09-23). +# A negative control runs first: without the provider configuration, `fips_mode true` must not +# give a working crypto (it fails closed: the NIF does not load). +# +# Not a validation claim: ubuntu-24.04 is not an operational environment #4985 names, and this +# job proves the package's behaviour under FIPS mode, nothing about the module. See docs/fips.md. +# +# When: every push to main and every pull request that touches the code, weekly, and on demand. +# The toolchain is built once and cached (keyed on the versions and this file); a warm run is +# the suite plus a few minutes. Public repository: GitHub-hosted minutes are not billed. +name: fips + +on: + push: + branches: [main] + pull_request: + paths: + - "lib/**" + - "test/**" + - "mix.exs" + - "mix.lock" + - ".github/workflows/fips.yml" + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fips: + name: FIPS - OTP 28.1.1, OpenSSL FIPS provider 3.1.2 (CMVP 4985) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + # Fixed absolute paths: the rpath, OpenSSL's MODULESDIR and OTP's ROOTDIR are baked in. + P: /home/runner/fips/ossl + OTP: /home/runner/fips/otp + EX: /home/runner/fips/elixir + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - id: cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /home/runner/fips + key: fips-${{ runner.os }}-${{ runner.arch }}-ubuntu24-otp28.1.1-ossl3.5.8-fipsprov3.1.2-ex1.18.4-${{ hashFiles('.github/workflows/fips.yml') }} + + - name: build OpenSSL 3.5.8 (libcrypto), the 3.1.2 FIPS provider, and OTP 28.1.1 --enable-fips + if: steps.cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends libncurses-dev + cd "$RUNNER_TEMP" + dl() { curl -fsSLO "$1"; echo "$2 $(basename "$1")" | sha256sum -c -; } + dl https://github.com/openssl/openssl/releases/download/openssl-3.5.8/openssl-3.5.8.tar.gz a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 + dl https://github.com/openssl/openssl/releases/download/openssl-3.1.2/openssl-3.1.2.tar.gz a0ce69b8b97ea6a35b96875235aa453b966ba3cba8af2de23657d8b6767d6539 + dl https://github.com/erlang/otp/releases/download/OTP-28.1.1/otp_src_28.1.1.tar.gz 03e1b26e846b2cc1b49271656da6d5ecf4d5f4c2e34224d869a23c61a3d5660a + dl https://builds.hex.pm/builds/elixir/v1.18.4-otp-28.zip cbd98899abdd4b1243d8ea6dae7258f9bc71cc2dcb55b8f9c241b07f04b3de67 + for t in openssl-3.5.8 openssl-3.1.2 otp_src_28.1.1; do tar xzf "$t.tar.gz"; done + (cd openssl-3.5.8 && ./Configure --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared no-docs && make -j4 && make install_sw) + (cd openssl-3.1.2 && ./Configure enable-fips --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared && make -j4 && make install_fips) + (cd otp_src_28.1.1 && ./configure --prefix="$OTP" --enable-fips --with-ssl="$P" --with-ssl-rpath="$P/lib" --without-javac --without-odbc --without-wx && make -j4 && make install) + mkdir -p "$EX" && unzip -q v1.18.4-otp-28.zip -d "$EX" + + - name: fipsinstall on this machine, and the configuration that activates the provider + run: | + set -euo pipefail + LD_LIBRARY_PATH="$P/lib" "$P/bin/openssl" fipsinstall -pedantic -out "$P/ssl/fipsmodule.cnf" -module "$P/lib/ossl-modules/fips.so" + printf '%s\n' 'config_diagnostics = 1' 'openssl_conf = openssl_init' ".include $P/ssl/fipsmodule.cnf" \ + '[openssl_init]' 'providers = provider_sect' 'alg_section = algorithm_sect' \ + '[provider_sect]' 'fips = fips_sect' 'base = base_sect' '[base_sect]' 'activate = 1' \ + '[algorithm_sect]' 'default_properties = fips=yes' > "$P/ssl/openssl-fips.cnf" + echo "OPENSSL_CONF=$P/ssl/openssl-fips.cnf" >> "$GITHUB_ENV" + echo "$OTP/bin" >> "$GITHUB_PATH" + echo "$EX/bin" >> "$GITHUB_PATH" + + - name: FIPS mode is on, served by the 3.1.2 provider, and fails closed without it + run: | + set -euo pipefail + erl -noshell -crypto fips_mode true -eval ' + {ok, _} = application:ensure_all_started(crypto), + enabled = crypto:info_fips(), + I = crypto:info(), + true = maps:get(fips_provider_available, I), + "3.1.2" = maps:get(fips_provider_buildinfo, I), + {'"'"'EXIT'"'"', _} = (catch crypto:hash(md5, <<"x">>)), + <<16#ba7816bf:32, _/binary>> = crypto:hash(sha256, <<"abc">>), + 48 = byte_size(crypto:hash(sha384, <<"abc">>)), + 64 = byte_size(crypto:hash(sha512, <<"abc">>)), + io:format("FIPS enabled: provider ~s over ~s~n", ["3.1.2", maps:get(cryptolib_version_linked, I)]), + halt(0).' + if env -u OPENSSL_CONF erl -noshell -crypto fips_mode true -eval 'enabled = crypto:info_fips(), halt(0).'; then + echo "FAIL: fips_mode true gave a working crypto without the provider configuration"; exit 1 + fi + echo "negative control: without the provider configuration, crypto does not load" + + - name: deps and compile, FIPS mode off + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + MIX_ENV=test mix compile + + - name: the suite, in FIPS mode, asserted in the same VM before any test runs + env: + ELIXIR_ERL_OPTIONS: "-crypto fips_mode true" + run: > + elixir + -e 'Application.load(:crypto)' + -e 'unless apply(:crypto, :info_fips, []) == :enabled, do: raise("FIPS mode is not enabled"); IO.puts("FIPS enabled, provider " <> to_string(Map.fetch!(apply(:crypto, :info, []), :fips_provider_buildinfo)))' + -S mix test diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 8fad03d9..58c73086 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -25,6 +25,13 @@ # hex.pm half reads NOT MEASURED -- whether hex.pm serves nothing for the version or serves # bytes this commit does not build -- which is what it is. # +# THE SBOM (from 0.10.0). tools/sbom.sh generates a CycloneDX 1.6 SBOM of the runtime +# dependency set from the same tree, outside mix.exs (the EEF's mix_sbom binary, pinned by +# version and digest; one named workaround, G-087), and actions/attest binds it to the +# tarball's digest as a second attestation (predicate https://cyclonedx.org/bom). A consumer +# verifies it with `gh attestation verify --predicate-type https://cyclonedx.org/bom` +# and fetches it with `gh attestation download` (docs/provenance.md). +# # CI is unproven until a run exists (CONVENTIONS): the first workflow_dispatch run is the # evidence for the attest->verify loop; the first tag after this lands is the evidence for # the hex.pm binding. Both are quoted in the slice record when they exist. @@ -80,6 +87,39 @@ jobs: - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: beam_mcp-${{ steps.build.outputs.version }}.tar + - name: the SBOM, generated outside mix.exs from the same tree (tools/sbom.sh) + run: tools/sbom.sh "${GITHUB_SHA}" "beam_mcp-${{ steps.build.outputs.version }}.cdx.json" + # Pinned by commit (v4.2.2, 2026-08-04): the SBOM attestation's subject is the same + # tarball digest; the predicate is the CycloneDX document itself. + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: beam_mcp-${{ steps.build.outputs.version }}.tar + sbom-path: beam_mcp-${{ steps.build.outputs.version }}.cdx.json + - name: verify the SBOM attestation against the tarball this run built + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh attestation verify "beam_mcp-${{ steps.build.outputs.version }}.tar" --repo "${GITHUB_REPOSITORY}" \ + --predicate-type https://cyclonedx.org/bom + # docs/provenance.md tells a consumer to fetch the SBOM this way; this step is that + # command, run, and its result compared with the file this run generated. + - name: the SBOM a consumer downloads is the document this run generated + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + v="${{ steps.build.outputs.version }}" + mkdir -p fetched && cd fetched + gh attestation download "../beam_mcp-${v}.tar" --repo "${GITHUB_REPOSITORY}" --predicate-type https://cyclonedx.org/bom + jq -S . "../beam_mcp-${v}.cdx.json" > generated.json + found=no + while IFS= read -r line; do + printf '%s' "$line" | jq -r '.dsseEnvelope.payload' | base64 -d | jq -S '.predicate' > candidate.json + if cmp -s candidate.json generated.json; then found=yes; fi + done < <(cat sha256:*.jsonl) + [ "$found" = yes ] || { echo "FAIL: no downloaded SBOM attestation carries the document this run generated"; exit 1; } + echo "the downloaded SBOM predicate equals the generated document" - name: verify the attestation against the tarball this run built env: GH_TOKEN: ${{ github.token }} @@ -119,5 +159,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: tarball-${{ steps.build.outputs.version }} - path: beam_mcp-${{ steps.build.outputs.version }}.tar + path: | + beam_mcp-${{ steps.build.outputs.version }}.tar + beam_mcp-${{ steps.build.outputs.version }}.cdx.json if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 5558625f..30679a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,59 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.10.0] - 2026-09-23 + +The quiet minor again: **no public entry added, removed, renamed, hidden or changed in arity** +(`docs/public-api.txt` did not move; `release_markers!("0.10.0")` wrote nothing), and no wire +byte or envelope byte moved: the recording's ten version lines are the only lines re-taken, +and every canonical golden is the same blob as at `0.9.0`. Instruments and pages only: the +suite in FIPS mode in CI on the validated OpenSSL FIPS provider, an attested SBOM at every +release, the pages the OpenSSF Best Practices badge asks for (silver on 2026-09-23), and the +documentation free of em dashes. The threat model's one edited row corrects a status (what +shipped), not what it defends. **How to tell whether you are affected:** you are not; raise +the pin to `~> 0.10.0`. `1.0.0` follows once this minor has stood (UPGRADING's road). + +### Changed: the pages, copy only + +- The em dash is gone from the public tree outside the archived `slices/` records: README, + CHANGELOG (the released headings too: `## [0.8.0] - 2026-09-19`, `### Added: ...`, + `### Changed (BREAKING): ...`), UPGRADING (its quotations follow), SECURITY, CONVENTIONS, + every page under `docs/`, the livebook, and the docstrings and comments under `lib/`. No + meaning, number, code span or link moved; ranges keep their en dash. +- README's "Scheduled" paragraph follows the road: the federation seam and effective + connectivity come after `1.0.0`, as additions, and the Tasks extension of `2026-07-28` is + named third (not built, not refused). `docs/roadmap.md` lists the same. + +### Added: an SBOM at every release, attested to the tarball (no public entry moves) + +- `tools/sbom.sh` and the release workflow: a CycloneDX 1.6 SBOM of the runtime dependency set, + generated from the tag's tree outside `mix.exs` with the EEF's `mix_sbom` 0.11.0 (pinned by + version and the release asset's SHA-256; nothing added to `mix.exs` or `mix.lock`), and + attested to the tarball's digest beside the build provenance. The workflow verifies it and + downloads it back the way `docs/provenance.md` tells a consumer to, comparing the result with + what it generated. One workaround is named in the script: `mix_sbom` 0.11.0 cannot read the + `tools: :optional` entry in `mix.exs`, so its scratch copy reads `:tools`; a fix is offered + upstream. Measured locally: 15 components, the nine runtime Hex packages at their locked + versions and six OTP/Elixir applications, no development or test dependency. +- `docs/threat-model.md`'s supply-chain row reads what shipped (provenance from `0.6.0`, the + SBOM from `0.10.0`) in place of "scheduled slices, not shipped". A status corrected, not a + change to what the model defends. + +### Added: the suite in FIPS mode, in CI (no public entry moves) + +- `.github/workflows/fips.yml`: the suite on Erlang/OTP 28.1.1 built with `--enable-fips`, over + OpenSSL 3.5.8's libcrypto with the FIPS provider built from OpenSSL 3.1.2 (the source CMVP + certificate #4985, FIPS 140-3, validates), every download checked against its published + SHA-256. It asserts FIPS mode inside the VM before a test runs, with a negative control, and + runs on pushes to `main`, pull requests touching the code, weekly and on demand; the + toolchain is cached. Measured 2026-09-23: 11 properties, 729 tests, 0 failures in FIPS + mode; MD5 refused, SHA-2 answering. +- `docs/fips.md`: a "Measured" section with those figures, and one correction the + measurement made: with FIPS mode requested and no provider, `crypto` does not fail to + start (the application answers `ok`); its module does not load, so a release boots and the + first `:crypto` call raises. The page said the release does not boot. Not a validation + claim, and the page says so. + ### Added: pages for the OpenSSF Best Practices badge, and Credo's two security warnings on `lib/` No public entry moves, no wire byte and no envelope byte; `lib/` changes by one comment. @@ -115,21 +168,21 @@ here, as `UPGRADING.md` states it: `0.10.0` a quiet minor in which no public ent package implements the first); this package copies what the host asserts and refuses no name, so the spec admits `term()` beside `t:BeamMCP.Connectome.Canonical.scheme/0`. -## [0.8.0] — 2026-09-19 +## [0.8.0] - 2026-09-19 -The quiet minor: **no public entry added, removed, renamed, hidden or changed in arity** — +The quiet minor: **no public entry added, removed, renamed, hidden or changed in arity**: `docs/public-api.txt` is line for line `0.7.0`'s, `release_markers!("0.8.0")` wrote nothing, and the census holds an empty Unreleased section to that. No wire byte and no envelope byte moves. Instruments and pages only, below. This is the "full minor release unchanged" the README names as `1.0.0`'s condition for the public API; `1.0.0` follows once it has stood. -### Changed — instruments (no public entry moves) +### Changed: instruments (no public entry moves) - **The gate diffs `docs/public-api.txt` against `origin/main`** (a sixteenth step, `baseline`; `tools/baseline_diff.sh`, probed by `tools/probe_baseline_diff.sh`): a public entry's line deleted by hand, or a `since=`/`deprecated_since=`/`removed_in=` marker arriving - with a release number not above the CHANGELOG's highest heading — one it lists, or a phantom - between two releases — is a FAIL. The census reads the tree + with a release number not above the CHANGELOG's highest heading (one it lists, or a phantom + between two releases) is a FAIL. The census reads the tree alone and passed both edits green (a review lane measured it); this step reads git. Where `origin/main` does not resolve the line says so and is not evidence. - **The pull-request summary waits for running legs** on a body edit (`tools/ci_legs_verdict.sh`, @@ -141,30 +194,30 @@ README names as `1.0.0`'s condition for the public API; `1.0.0` follows once it (it counted every detailed line: 7 of its 13 names on a green gate, 10 of the 16 the gate prints now; 0 now). -### Changed — pages (copy) +### Changed: pages (copy) - `docs/governance.md`'s Scorecard table carries the check's own figure beside each row - (read 2026-09-19; aggregate 7), with the rule behind each low one — Maintained is 0 for any + (read 2026-09-19; aggregate 7), with the rule behind each low one (Maintained is 0 for any repository under 90 days old; Signed-Releases and Packaging read GitHub Releases and a - publishing workflow, neither of which a Hex release has — and two rows the first result + publishing workflow, neither of which a Hex release has), and two rows the first result added (Packaging, CII-Best-Practices). - `docs/provenance.md`'s example commands name `0.7.0`; `docs/connectome-canonical.md`'s `schema_version` history no longer says `0.6.0` "carries this note". -## [0.7.0] — 2026-09-19 +## [0.7.0] - 2026-09-19 -The signer seam, and nothing else: three public entries added — `BeamMCP.Signer` (the +The signer seam, and nothing else: three public entries added: `BeamMCP.Signer` (the behaviour, `c:BeamMCP.Signer.sign/2`), `BeamMCP.Signer.None.sign/2` and -`BeamMCP.Connectome.Canonical.signature/3` — the last intentional addition to the public API +`BeamMCP.Connectome.Canonical.signature/3`, the last intentional addition to the public API before `1.0.0`. **No break**: no public entry is removed, renamed or hidden, no wire byte and no envelope byte moves. The signer that holds a key is the separate package `beam_mcp_signer`; this package does not depend on it. The road from here, as `UPGRADING.md` states it: `0.8.0` is a quiet minor in which no public entry is added, removed, renamed or hidden; `1.0.0` follows once that minor has stood. -### Added — the signer seam: one behaviour, one no-op, one call site; the key stays outside +### Added: the signer seam: one behaviour, one no-op, one call site; the key stays outside -- **`BeamMCP.Signer`** is a behaviour with exactly one callback, **`c:BeamMCP.Signer.sign/2`** — +- **`BeamMCP.Signer`** is a behaviour with exactly one callback, **`c:BeamMCP.Signer.sign/2`**: `sign(canonical_bytes :: binary(), opts :: keyword()) :: {:ok, binary()} | {:error, term()}`. Bytes in, signature out, nothing else: two arguments with those names, and a census pins the module, the callback, the arity and the names, so any widening is a visible act @@ -172,7 +225,7 @@ follows once that minor has stood. what; a richer callback would). - **`BeamMCP.Signer.None.sign/2`** is the one implementation in this package and signs nothing: `{:error, :no_signer}`, whatever the bytes and options. -- **`BeamMCP.Connectome.Canonical.signature/3`** — `signature(graph, signer, opts)` — is the one +- **`BeamMCP.Connectome.Canonical.signature/3`**, `signature(graph, signer, opts)`, is the one site that calls a signer: it encodes the graph with `encode/2`, hands exactly those bytes to the host-supplied module with the options as the host gave them (this package reads only `:algorithm` from them), and returns `{:ok, %{algorithm: algorithm, signature: signature, @@ -184,39 +237,39 @@ follows once that minor has stood. - **The reference signer that holds a key is the separate package `beam_mcp_signer`** ([github.com/ScriptKittyOS/beam_mcp_signer](https://github.com/ScriptKittyOS/beam_mcp_signer)): `BeamMCP.Signer.Ed25519`, Ed25519 through OTP's `:crypto`, the 32-byte private key handed in - by the host under `opts[:private_key]` on every call and read from nowhere else — no + by the host under `opts[:private_key]` on every call and read from nowhere else: no environment variable, no file, no application config. A host that wants signatures adds that package and attaches the module; this package does not depend on it and never will. This package still holds no key and calls no signing primitive; `docs/will-not-implement.md` entry 3, `docs/crypto-posture.md` and the threat model now say "makes no signature of its own" and name the seam, and the no-signature census pins it instead of forbidding it: red first on the tree without the seam, then red on - each plant — a third callback argument, a renamed argument, a second `def sign`, a + each plant: a third callback argument, a renamed argument, a second `def sign`, a `:crypto.sign` call, a second signer call site, the no-op answering `{:ok, <<>>}`, and, after review, a second behaviour of the seam's shape, a widened return, a `def(sign(` and a hidden callback on an allowed behaviour. -## [0.6.0] — 2026-09-19 +## [0.6.0] - 2026-09-19 Everything since `0.5.0`, entry by entry below: the wire hardening that followed the threat model (the stdio loop and JSON nesting fixes, the HTTP body read deadline, the HTTP/2 control-frame residue bounded by a connection deadline), the threat-model page itself, the hash-agile -canonical envelope, then the install-floor work (slices 022–028) — the OTP floor at compile +canonical envelope, then the install-floor work (slices 022–028): the OTP floor at compile time, the CI matrix, the dependency audit, build provenance, the security policy, the instruments, the tracer in its own trace session, the API-stability policy with the public -surface pinned — and governance, the export-control statement and REUSE compliance after it. +surface pinned; and governance, the export-control statement and REUSE compliance after it. **One documented break at the minor**, in the exported bytes: the canonical envelope names its algorithm and `schema_version` is `3` (its entry below, with the how-to-tell sentence). **The road from here, -as `UPGRADING.md` states it:** `0.7.0` carries the signer seam — `BeamMCP.Signer`, a behaviour -added to the public surface, and no authority — the last intentional addition to the public +as `UPGRADING.md` states it:** `0.7.0` carries the signer seam (`BeamMCP.Signer`, a behaviour +added to the public surface, and no authority), the last intentional addition to the public API; `0.8.0` is a quiet minor in which no public entry is added, removed, renamed or hidden; `1.0.0` follows once that minor has stood. -### Added — an export-control statement, and REUSE compliance by the specification's own tool +### Added: an export-control statement, and REUSE compliance by the specification's own tool - **The README's "Export control" section** says, for the compliance reader, what the package is (public, Apache-2.0, on GitHub and hex.pm; no encryption; one SHA-2 digest site, SHA-256 default and SHA-384/SHA-512 by option, for integrity hashing; no key material) and the - maintainer's reading of 15 CFR 734.7(a)(4) — published software is not subject to the EAR — + maintainer's reading of 15 CFR 734.7(a)(4) (published software is not subject to the EAR), with 734.7(b) and 742.15(b) named for why the encryption exception does not reach a digest-only library, with 772.1's definitions of "cryptography" and "encryption software" named; it says what its own code contains and that the optional HTTP dependencies are the integrator's; it @@ -227,17 +280,17 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename - **The tree is REUSE-compliant by `reuse lint`** (REUSE Specification 3.3), not only by the gate's header check: `REUSE.toml` annotates the slice archives, the two scripts that write or plant header text mark those lines for the tool, and CI runs the pinned tool over the whole - tree in the conformance job. Measured before: four "invalid expressions" — two scripts' + tree in the conformance job. Measured before: four "invalid expressions" (two scripts' string literals, and the bare licence-identifier tag quoted with nothing after it in two - archived files, one of them headered — and 198 archive files without information; after: 0 + archived files, one of them headered) and 198 archive files without information; after: 0 and 648 of 648. (The tag is not spelled here for the same reason.) -### Added — governance and succession stated as they are; the Scorecard measured; the workflows pinned +### Added: governance and succession stated as they are; the Scorecard measured; the workflows pinned - **`docs/governance.md`** says who decides (one maintainer, an org-owned repository, no second reviewer), how every change lands (a pull request under the ruleset, the fifteen-step gate on three pairs, review by tier, the merge word), and, check by check, what the OpenSSF - Scorecard reads of it — which checks this project keeps, which it cannot (one maintainer + Scorecard reads of it: which checks this project keeps, which it cannot (one maintainer cannot approve their own pull request) and which it has decided against (no CodeQL, no GitHub Release beside the Hex release), so a low mark reads as the decision it is. - **`docs/succession.md`** says the bus factor is one, what survives the maintainer (the @@ -249,37 +302,37 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename - **The Scorecard runs** (`.github/workflows/scorecard.yml`) on every push to `main` and once a week, publishing to the OpenSSF API and to the code-scanning tab; least privilege, pinned. - **Every workflow action is pinned by commit SHA with its version beside it** (a tag can be - moved), and every workflow declares top-level `permissions:` with no write — the two jobs + moved), and every workflow declares top-level `permissions:` with no write; the two jobs that write hold it at the job, and no other job does. A census (`test/beam_mcp/governance_test.exs`) holds the pins, the top-level permissions (a `write-all` included), which jobs may write, CODEOWNERS and the pages' stated facts on every push; red first on the tree as it was (seven failures: no CODEOWNERS, no pages, two workflows unpinned and without permissions). -### Added — the API stability policy, the public surface pinned, and an upgrade guide +### Added: the API stability policy, the public surface pinned, and an upgrade guide - **`docs/api-stability.md`** says what a `~>` pin can rely on: public is what ex_doc lists (`@moduledoc false` and `@doc false` are the private surface); `0.x` breaks land at the - minor and say so; from `1.0.0` semantic versioning; a deprecation runs in three steps — - `@deprecated` with the replacement already shipped, three minors of warning, removal on - the record (and, on `1.x`, the removal in a major — the release step refuses any other - number) — and a docs-hidden flip is a removal. + minor and say so; from `1.0.0` semantic versioning; a deprecation runs in three steps + (`@deprecated` with the replacement already shipped, three minors of warning, removal on + the record; on `1.x` the removal is in a major, and the release step refuses any other + number), and a docs-hidden flip is a removal. - **`docs/public-api.txt`** is the surface itself, one line per function, macro, callback or - type with its default-argument count — 26 modules, 128 entries at this writing — written + type with its default-argument count (26 modules, 128 entries at this writing), written by a command from the compiled application that adds, deprecates and removes lines' markers itself (`since`, `deprecated_since`, `removed_in`: a release number, or `Unreleased` until the release writes its number in) and never deletes a line. It ships in the tarball. - **A census test holds the surface to the record** (`test/beam_mcp/public_api_census_test.exs`): a documented public entry may change only if, in the same change, the baseline moves, this file's Unreleased section names the - exact `Module.name/arity`, and the kind's condition holds — with no OR between the kinds. + exact `Module.name/arity`, and the kind's condition holds, with no OR between the kinds. Shown red before it was trusted, with the baseline unchanged: a public function deleted, renamed, hidden, a default argument dropped; with the baseline moved and this file silent; with a sibling deprecated instead; with this file naming it but the `@deprecated` first added in the same change; with the `0.x` sentence but no BREAKING heading or no how-to-tell sentence; on a `1.x` tree with two minors or with the `0.x` sentence; and each half of a - deprecation without the other. Green: three minors of `deprecated_since` on the tree, or — - `0.x` only — a bullet that says *documented break at the minor* under a BREAKING heading + deprecation without the other. Green: three minors of `deprecated_since` on the tree, or + (`0.x` only) a bullet that says *documented break at the minor* under a BREAKING heading with the how-to-tell sentence. From this release a break's heading says BREAKING and its section carries that sentence; the census holds the Unreleased section to it. The rule is one pure function, run on the tree and on literal fixtures, so every path is held on a tree @@ -287,17 +340,17 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename - **`UPGRADING.md`**: every `0.x` break so far in one table, the rule for moving a minor, and what `1.0` will ask (today: nothing beyond the minors, since no entry is deprecated). -### Changed — the connectome tracer runs in a trace session of its own +### Changed: the connectome tracer runs in a trace session of its own - **`BeamMCP.Connectome.Tracer` sets everything inside one OTP trace session** (`:trace.session_create/3`, OTP 27) whose tracer is the tracer process, and every clear is one `:trace.session_destroy/1`. What that changes for a host: a process the host already traces under its own tracer is traced by the session too, so its calls are edges (the - legacy tracer skipped such a process silently — one tracer per process); a host's own + legacy tracer skipped such a process silently: one tracer per process); a host's own pattern on a module the tracer names is neither fed by the tracer's pattern nor touched by its clear; the `processes:` refusal for a host-traced pid is gone (a name registered to a port, or whose holder exited between the check and the start, is the `init_failed` cause - that remains); and nothing is left behind on any exit path — the double-kill window the + that remains); and nothing is left behind on any exit path: the double-kill window the observed page used to name closes by physics, since a session whose every handle is gone is destroyed by the BEAM. The public running term `{BeamMCP.Connectome.Tracer, :running}` and its stale, forged and malformed cases no longer exist; `stop/0` reads the tracer's @@ -308,22 +361,22 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename a handle dying destroys the session within 20 ms; a dead tracer's session still held keeps its patterns (the BEAM drops its flags) at a cost per call from the baseline's order to 2.4 times it; a `:send` trace copies the sent term into the tracer's mailbox at its size (16 MB - for a million-element list) — said on the page now. 22 tracer mutants, 22 killed on the tree that ships; four + for a million-element list), said on the page now. 22 tracer mutants, 22 killed on the tree that ships; four equivalent-by-physics mutants removed as code with the reason. - **The OTP floor's reason is now one number.** OTP 27.0's `trace` module is the hard requirement and 27 is the oldest supported release; `mix.exs`'s raise text, the README and the floor test say so (the previous reason, the keyed `process_info` read of OTP 26.2, put the hard requirement one major below the floor). -### Fixed — a tracer duration beyond the BEAM's timer range is refused, not accepted and ended at once +### Fixed: a tracer duration beyond the BEAM's timer range is refused, not accepted and ended at once -- **`max_duration_ms` above 4 294 967 295** — the largest timeout a `receive … after` takes — +- **`max_duration_ms` above 4 294 967 295** (the largest timeout a `receive … after` takes) is `{:error, {:invalid, :max_duration_ms, value}}` now. It used to answer `{:ok, pid}`: the companion's deadline is that `after`, so it raised `:timeout_value` on its first instruction and the tracer left `{:shutdown, :companion_gone}` with an error log, having traced nothing. Found while measuring the session build; red first. -### Added — opt-in git hooks that run the gate +### Added: opt-in git hooks that run the gate - **`tools/install-hooks.sh`** points a clone's `core.hooksPath` at the tracked `tools/hooks/`: `pre-commit` runs `tools/gate.sh` on the tree about to be committed, `commit-msg` reads the @@ -333,18 +386,18 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename refused, a green one landed, a term refused, the bypass, uninstall, and a hook that is not executable refused by the installer. -### Added — the instruments: Dialyzer in the gate, a mutation harness that names a compiler kill and scores in scope, the pull-request body held to the gate's terms, and the gate pinning its own environment +### Added: the instruments: Dialyzer in the gate, a mutation harness that names a compiler kill and scores in scope, the pull-request body held to the gate's terms, and the gate pinning its own environment - **Dialyzer is the gate's fifteenth step**, on the OTP binary, with a PLT under `_build` - keyed by the OTP/Elixir pair and the lock — built cold once per machine (about a minute + keyed by the OTP/Elixir pair and the lock, built cold once per machine (about a minute here; 84–107 s on a CI runner, once per cache key) and cached in CI; the analysis is a few seconds. Zero warnings on OTP 27, 28 and 29. What it found on the way: three opaque-`MapSet` - warnings in the reachability DFS that were Dialyzer's known false positive — the DFS's + warnings in the reachability DFS that were Dialyzer's known false positive; the DFS's `seen` set is a plain map now, the same set with nothing opaque. - **The mutation harness** (`tools/mutate.sh`) reads a compilation failure as - `COMPILER-KILL — not a kill` (a mutant that orphans a symbol under `warnings_as_errors` + `COMPILER-KILL -- not a kill` (a mutant that orphans a symbol under `warnings_as_errors` used to read like a kill), and `SCOPE=derived` scores against the test files that name the - target's module, re-scoring a survivor against the whole suite before calling it one — a + target's module, re-scoring a survivor against the whole suite before calling it one: a tracer score in seconds, with no survivor bought by the speed-up. A probe plants both. - **The pull-request body** is held to the same terms as commit messages (an attribution trailer, a session link, a board id, a consumer's name), by a CI job that reads it from the @@ -357,12 +410,12 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename are held wider (a 404 on a tag, a fake `gh`, a reflow, a fifth severity row, an older row marked supported); a beam holding a different module than its name has its own test. -### Changed — the security policy names severity in the package's own terms, the CVE path, and what it does not claim +### Changed: the security policy names severity in the package's own terms, the CVE path, and what it does not claim -- **`SECURITY.md`** gains a severity rubric — four levels defined by what a defect lets a client +- **`SECURITY.md`** gains a severity rubric (four levels defined by what a defect lets a client do to the embedding host (reach dispatch past the advertised schema; be served under an undeclared revision; exceed a bound with a stated workaround; a wrong code or message), each - with the fix window intended at assessment — and the CVE path: advisories publish from this + with the fix window intended at assessment) and the CVE path: advisories publish from this repository's GitHub Security Advisories, GitHub is the CNA, and a published advisory reaches OSV, the source hex.pm fills its registry advisories from and `mix hex.audit` reads, so a consumer's own audit shows it. The policy says it @@ -372,18 +425,18 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename minor. **The policy ships**: `SECURITY.md` is in the Hex tarball and among the docs pages, and names the maintainer's address for a report that cannot go through the advisory form. -### Added — the release tarball is attested, and the attestation binds to the checksum hex.pm shows +### Added: the release tarball is attested, and the attestation binds to the checksum hex.pm shows - **Build provenance on every release tag** (`.github/workflows/provenance.yml`): CI builds the Hex tarball, attests its SHA-256 with GitHub's build-provenance attestation (SLSA provenance, Sigstore-signed, pinned action), verifies the attestation against the tarball it built, and - then against the bytes hex.pm serves for that version, downloaded fresh — so the assertion is + then against the bytes hex.pm serves for that version, downloaded fresh, so the assertion is about the published tarball, and on a tag a tarball hex.pm does not serve is a failure. The owner tags and publishes; the workflow attests and never publishes. `docs/provenance.md` says how to verify (`gh attestation verify`, gh ≥ 2.49) and how to reproduce the bytes. - **The release tarball is built one way on every machine** (`tools/release_tarball.sh`): a working-tree `mix hex.build` carries that machine's file modes and a directory's readdir - order — one commit gave three checksums on one day, measured — so the script `git archive`s + order (one commit gave three checksums on one day, measured), so the script `git archive`s the ref with `tar.umask=022` (every file `644`, tracked files only) and `mix.exs` names its `files:` as globs (sorted order). The same commit built on ext4 and on tmpfs gives one tarball; a test pins the structure. The outer tarball's SHA-256 is the "Package checksum" @@ -391,82 +444,82 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename when it was published with the script (`--publish`); `0.5.0` and earlier were built from working trees and carry no attestation. -### Added — the dependency audit is a gate step, and an answer hex gives without the registry is not a pass +### Added: the dependency audit is a gate step, and an answer hex gives without the registry is not a pass - **`audit`, the gate's fourteenth step:** no retired package and no package with a security - advisory in `mix.lock`. It runs `mix hex.audit` — built into Hex, no dependency of this - package — whose advisory feed is OSV's (each advisory carries `api.osv.dev/v1/vulns/`, + advisory in `mix.lock`. It runs `mix hex.audit` (built into Hex, no dependency of this + package), whose advisory feed is OSV's (each advisory carries `api.osv.dev/v1/vulns/`, with CVE and GHSA aliases; the EEF CNA's ids), so the one call is the retirement audit and the OSV audit. Findings a project ignores by Hex's `ignore_advisories` / `ignore_retirements` come back from hex as "Ignored" sections and are printed after the pass line, so a pass over an ignore is never silent. Red first, in a throwaway consumer carrying a retired, advisoried release; a tracked probe keeps that plant outside the tree. -- **Hex answers from its cache when it cannot reach the registry — and exits 0.** With the +- **Hex answers from its cache when it cannot reach the registry, and exits 0.** With the registry unreachable it prints "using cache instead" per package and then "No retired or security advisory packages found"; in its own offline mode it prints nothing at all. The step forces online mode and reads those lines, and refuses either as a measurement: locally the line reads NOT MEASURED and the gate does not fail (a contributor offline is not wrong); in CI every leg requires the measurement and a cached answer fails the gate. -### Added — the CI gate runs on three OTP/Elixir pairs, so the floor is a measurement +### Added: the CI gate runs on three OTP/Elixir pairs, so the floor is a measurement -- **The CI gate is a matrix:** the floor pair (OTP 27 / Elixir 1.17 — the oldest pair the +- **The CI gate is a matrix:** the floor pair (OTP 27 / Elixir 1.17, the oldest pair the compatibility table lists for OTP 27), the pinned pair (28 / 1.18, `.tool-versions`' line) and the head pair (29 / 1.20, the newest listed). The floor leg is what turns "beam_mcp - supports OTP 27" — the compile-time floor above — from a sentence into a measurement; until it + supports OTP 27" (the compile-time floor above) from a sentence into a measurement; until it ran, the floor was a claim about a release nobody had run the suite on. `mix format` is measured on the pinned leg only: the formatter changes between Elixir minors, so on the other legs the gate's format step reads NOT MEASURED rather than passing on a program that never ran or failing a tree that is not wrong. The bench step's thresholds are enforced only where the - machine is known — the maintainers' — and on a CI runner the figures are recorded beside them + machine is known (the maintainers'), and on a CI runner the figures are recorded beside them (a draw over its ceiling reads OVER, never red): on a shared runner the same code measured 129–261 ms against a 245 ms ceiling, so there the step measures contention, not the code. A bench script that crashes still fails the step everywhere. - **What the legs found, fixed at the source:** on Elixir 1.20 the test-only h2c client's frame parser used a variable bound outside a match inside `binary-size` without the pin (now - `^len`); on OTP 29 the stdlib gained a `graph` module, so the atom `:graph` — a key in this - package's maps — names a module there, which the package-reach census now knows; and OTP + `^len`); on OTP 29 the stdlib gained a `graph` module, so the atom `:graph` (a key in this + package's maps) names a module there, which the package-reach census now knows; and OTP 29.1's xref crashes on a beam stripped of debug information instead of refusing it, so the declared builder now classifies such a beam itself, by its own `Dbgi` chunk, before xref sees - the file — one `:beam_lib.chunks/2` read, the same answer on every release. The censuses that + the file: one `:beam_lib.chunks/2` read, the same answer on every release. The censuses that pinned one compiler's spelling (a struct's own `__struct__/1`, `String.to_atom/1`'s inlining, - what `quote` compiles to, and Elixir 1.20 reaching `:re` directly for `Regex` — required + what `quote` compiles to, and Elixir 1.20 reaching `:re` directly for `Regex`, required there, refused on the compilers that do not inline it) now say what they mean on all three. - **The sidecar's float placement is measured on the floor.** `docs/connectome-canonical.md` said the sidecar raises on an OTP older than 25; no such release can compile this package (the floor is 27), so the sentence now says what is true: every release the package runs on has `:short`, and the thirteen placement examples run on OTP 27, 28 and 29. -### Changed — the OTP floor is enforced at compile time, with its reason; the Elixir bound made coherent with it +### Changed: the OTP floor is enforced at compile time, with its reason; the Elixir bound made coherent with it - **Erlang/OTP 27 is the floor, and a below-floor build now fails to compile** with a message that names the floor, the version found and why. Mix has an `elixir:` requirement but none for OTP, so `mix.exs` reads `:erlang.system_info(:otp_release)` at `project/0` and raises below - 27 — rather than compiling and failing later in a way that looks like a defect here. The + 27, rather than compiling and failing later in a way that looks like a defect here. The reason travels with the number, in the message and in the README's "The OTP floor" section, pinned equal by a test: OTP **26.2** added the keyed `:erlang.process_info(pid, {:dictionary, key})` read the connectome tracer depends on (one claim key, 2 µs, against copying the whole - dictionary — up to a millisecond on a loaded tracer, measured), so 26.2 is the hard + dictionary, up to a millisecond on a loaded tracer, measured), so 26.2 is the hard requirement; 27 is the oldest release this project supports. At the time of this entry the suite ran on OTP 28 only; the CI matrix (the entry above) has since measured it on 27 and 29. - **`elixir: "~> 1.17"`, from `"~> 1.15"`.** Elixir 1.15 and 1.16 support OTP 24–26 only, and 1.17 is the oldest Elixir that supports OTP 27, so the two stated minimums are now one coherent pair: Elixir 1.17 on OTP 27. No consumer is admitted or refused that the OTP floor - did not already decide — Elixir 1.15/1.16 are not supported on OTP 27, and Mix only warns on - a dependency's `elixir:` mismatch whereas the OTP guard is a hard stop — so this is a + did not already decide: Elixir 1.15/1.16 are not supported on OTP 27, and Mix only warns on + a dependency's `elixir:` mismatch whereas the OTP guard is a hard stop, so this is a statement made true, not a requirement raised. -### Added — the HTTP/2 control-frame residue is bounded in duration by a connection deadline +### Added: the HTTP/2 control-frame residue is bounded in duration by a connection deadline -- **`connection_timeout:` on `BeamMCP.Transport.HTTP`** — a positive integer of milliseconds, +- **`connection_timeout:` on `BeamMCP.Transport.HTTP`**: a positive integer of milliseconds, default twice `:read_timeout`. Over HTTP/2 a stream held open by control frames alone (a - WINDOW_UPDATE, or a HEADERS without END_STREAM) cannot be ended from outside the adapter — - the residue the slow-client threat-model row records — but its **connection** can. When a + WINDOW_UPDATE, or a HEADERS without END_STREAM) cannot be ended from outside the adapter + (the residue the slow-client threat-model row records), but its **connection** can. When a body read has been blocked for the connection deadline and nothing else on the connection is still within its own body deadline, the whole connection is closed with a `GOAWAY` the client can read: an OTP `GenServer.stop` on the socket handler (found by the `handle_shutdown/2` callback it exports, not by an adapter name), which runs the handler's own orderly - termination — `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. Measured: a stream + termination: `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. Measured: a stream pinned by control frames for three seconds under `connection_timeout: 600` is closed at ~605 ms, where without the bound it lived as long as the frames came (~3.34 s). So the residue is bounded in duration by this option and in count by `http_2_options`'s @@ -474,18 +527,18 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename - **The cost is per connection, and stated:** the client's other streams still open on the connection end with the `GOAWAY`. A legitimate stream that already answered is unharmed; one still in flight when the close fires dies with it, so a host multiplexing streams that outlive - one body read raises `connection_timeout`. The default is twice `read_timeout` — one for the + one body read raises `connection_timeout`. The default is twice `read_timeout`: one for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the read loop answers slow arrivals per DATA frame within `read_timeout`). - **How to tell whether you are affected:** a host that never faced this residue (no HTTP/2, or clients that always complete or abandon a body) sees no change. A host that ran HTTP/2 and had connections pinned by control-frame floods now sees them closed with a `GOAWAY` at the connection deadline rather than held until the client stops. The two per-stream residue tests - stay, carrying a high `connection_timeout` so the connection bound does not close them first — + stay, carrying a high `connection_timeout` so the connection bound does not close them first; they are the offer that fails the day `Bandit` bounds the stream itself. -### Changed — BREAKING (the exported bytes): the canonical envelope names its algorithm; `schema_version` 3; SHA-384 and SHA-512 by option +### Changed (BREAKING, the exported bytes): the canonical envelope names its algorithm; `schema_version` 3; SHA-384 and SHA-512 by option - **The algorithm is in the bytes.** A canonical envelope carries a fourth top-level member, `"algorithm"`, right after `"schema_version"`: `"sha256"`, `"sha384"` or `"sha512"`, and its @@ -495,62 +548,62 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename keys sort, so it comes first; `docs/connectome-diff.md`). **`schema_version` is `3`** on the graph, on the diff record and on the sidecar (which follows the graph's version and names no algorithm, since it is never hashed), and `BeamMCP.Connectome.Graph.new/1` refuses `2` as it refused - `1` — bytes are produced and hashed here, never re-imported. **Breaking for a consumer that + `1`; bytes are produced and hashed here, never re-imported. **Breaking for a consumer that parses the envelope with a fixed member list**; nothing else about the layout moved. - **SHA-256 stays the default, indefinitely; the other two are an option, never a constant.** `algorithm:` on `BeamMCP.Connectome.Canonical.encode/2`, `hash/2`, `hash_hex/2`, `hash_value/2`, on `BeamMCP.Connectome.Diff.encode/2`, `hash/2`, `hash_hex/2`, and in `BeamMCP.Connectome.Surface`'s host options; anything outside the three is refused by `ArgumentError` naming it, before a byte is written. The digest is computed at one site under - `lib/`, with the algorithm a variable — the key-holding census now pins one site, not two. + `lib/`, with the algorithm a variable; the key-holding census now pins one site, not two. The wire's `tools/call` result keys the hex by the algorithm's name: `sha256` as before under the default, `sha384` or `sha512` when the host chose one. - **A verifier holding 0.4.0 or 0.5.0 bytes** (`schema_version` 1 or 2) hashes them, unchanged, - with SHA-256 and compares — those bytes name no algorithm, and at those versions the digest is + with SHA-256 and compares; those bytes name no algorithm, and at those versions the digest is SHA-256 by the page's rule; published hashes stay verifiable forever. The canonical page's *Versions* section says so in full, and 0.5.0's goldens are kept in the tree and verified that way by a test. - **Two pages:** `docs/crypto-posture.md` (one primitive at one site; three digests named in the bytes; no key, no signature; the seam for a signing package) and `docs/fips.md` (what a - FIPS-mode host needs — a `crypto` built against a validated OpenSSL FIPS provider, - `application:start(crypto)`, `enable_fips_mode/1` or `fips_mode: true` — and that this + FIPS-mode host needs: a `crypto` built against a validated OpenSSL FIPS provider, + `application:start(crypto)`, `enable_fips_mode/1` or `fips_mode: true`; and that this package enables none of it and cannot, by census). - **Fixed on the way: the `.app` now requires `crypto`.** It did not; an HTTP host had it only through `plug` and `bandit`, both optional, and a stdio-only release built from the `.app` would have had no `:crypto.hash/2` at all. Found by writing the FIPS page's sentence about it and reading the built `.app`; pinned by a test that reads the `.app`. - **How to tell whether you are affected:** a consumer that verifies by hashing the bytes it - holds is not — the member is under the hash like every other. A consumer that parses by + holds is not: the member is under the hash like every other. A consumer that parses by position or by a fixed member list meets the member in three artefacts, each differently: the **graph envelope** carries `"algorithm"` at the second position (the order is fixed); the **diff record** carries it at the first (its keys sort); the **sidecar** carries no - member at all and moves its `schema_version` from `2` to `3` with the graph's — a consumer + member at all and moves its `schema_version` from `2` to `3` with the graph's; a consumer gating the sidecar on `2` refuses it. `schema_version` is `3` in all three. A host that passes nothing gets `sha256` everywhere it did. Latency stays out of the signed envelope, by decision: it is a measurement of one machine on one day, not a property of the graph, and lives in the unsigned sidecar as before. -### Added — the HTTP body read deadline is the Plug's option, and its default is a chosen number +### Added: the HTTP body read deadline is the Plug's option, and its default is a chosen number -- **`read_timeout:` on `BeamMCP.Transport.HTTP`** — one whole-body deadline, this package's +- **`read_timeout:` on `BeamMCP.Transport.HTTP`**: one whole-body deadline, this package's own. The body is read in pieces against one monotonic clock, each read given what remains, so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads. Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms - for 1,500 ms. The default is 15,000 ms — the number the transport has been running under — + for 1,500 ms. The default is 15,000 ms (the number the transport has been running under), but chosen now, with its reasoning beside the constant: for two releases this package passed no `:read_timeout` and the value in force was `Bandit`'s default for such a call, which the README called "inherited from the server"; a review lane read `Bandit` and `ThousandIsland` and found no server option for the body read, so nobody had chosen it and no host could - change it. And the adapter's `:read_timeout` is a per-read clock, not a whole-body one — - three ways, each measured by a lane and each closed: a cap-sized body is two reads and got + change it. And the adapter's `:read_timeout` is a per-read clock, not a whole-body one. + Three ways, each measured by a lane and each closed: a cap-sized body is two reads and got two deadlines (1,909 ms for 1,000); a chunked body is read chunk by chunk, each on its own - clock, and a client sending one byte per chunk was served after 43 s under the 15 s default — + clock, and a client sending one byte per chunk was served after 43 s under the 15 s default, so **`transfer-encoding: chunked` is refused with `411` before the body is read** (an MCP request is one complete JSON message under a 1 MiB cap; no MCP client this package has been run against sends one); and over HTTP/2, which `Bandit` serves on the same listener, the reader gathers DATA frames on a per-frame clock and a one-byte-per-frame drip of a valid call - was served after 20 s under a 300 ms deadline — now the reader is asked for less than one + was served after 20 s under a 300 ms deadline; now the reader is asked for less than one frame, so every DATA frame, an empty one included, returns to the deadline's clock (a stream kept open by control frames alone is held by the adapter's own wait past the deadline; the threat model states it, with the cost). Over HTTP/2 every refusal issued before @@ -564,36 +617,36 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename line with no body and is now this package's JSON-RPC refusal (`-32600`, the deadline named) with `connection: close` over HTTP/1.1; and the adapter's error-level log line at its read timeout (`Bandit.HTTPError Read timeout`) no longer fires, because the deadline is no longer - the adapter's — a host alerting on that line loses the signal, and nothing is written for a + the adapter's; a host alerting on that line loses the signal, and nothing is written for a `408` or a `411`. A client whose author sent a chunked body gets `411` until it sends a `Content-Length`. -### Added — the threat model, package-wide, with the wire bounded vector by vector +### Added: the threat model, package-wide, with the wire bounded vector by vector -- **`docs/threat-model.md`**: who is trusted for what — the host for everything, the client on +- **`docs/threat-model.md`**: who is trusted for what (the host for everything, the client on the wire for nothing, the node for everything by physics, this package for holding no tool, - key, signature, session, authority or client — and the wire vector by vector, each + key, signature, session, authority or client) and the wire vector by vector, each **refused**, **bounded** or **delegated** to the HTTP server or the host by decision, with the test that enforces it by path and by name and the OWASP entry it answers to (the *OWASP Top 10 - for Agentic Applications for 2026*, published 2025-12-09; the 2025 LLM Top 10, by edition — + for Agentic Applications for 2026*, published 2025-12-09; the 2025 LLM Top 10, by edition, read from the source). It extends the tracer's threat model shipped in 0.4.0 rather than replacing it: an adversary executing code inside the same node stays out of scope, now for every module, with the reason; prompt injection through tool results is the host's - (LLM01:2025) — this package carries bytes and never reads them. A federation section states - the trust domains a merge would cross — attribution, identity, signs, integrity in transit, - the peer as a client — so the seam is designed against them. Where the Plug goes in a host's - pipeline: ahead of `Plug.Parsers`, or its path excluded — behind the parsers every request is + (LLM01:2025): this package carries bytes and never reads them. A federation section states + the trust domains a merge would cross (attribution, identity, signs, integrity in transit, + the peer as a client), so the seam is designed against them. Where the Plug goes in a host's + pipeline: ahead of `Plug.Parsers`, or its path excluded; behind the parsers every request is `-32700 Parse error: empty body` (measured). A census holds every citation on the page to a test in the tree, the discipline the will-not-implement page is held by. Two things the page corrects in the README on the way: the body read's 15,000 ms is `Bandit`'s default for a - `read_body/2` call that passes no timeout — which is what this package passes — and not a + `read_body/2` call that passes no timeout (which is what this package passes) and not a server option a host can set; and the 12,000-in-flight measurement is dated to its record (2026-09-07). - **JSON nesting is bounded before the decoder runs, on both transports.** The 1 MiB body cap bounds how deep a body can nest but not what decoding it costs: a 1 MiB body nested 524,288 - levels deep was decoded in full — 79–96 ms and a 38 MiB heap for one request, about 36× the - body — and refused only afterwards by its shape. `BeamMCP.JSON.decode/1`, now the one place - the wire's JSON is read, walks the bytes once — brackets outside strings, escapes honoured — + levels deep was decoded in full (79–96 ms and a 38 MiB heap for one request, about 36× the + body) and refused only afterwards by its shape. `BeamMCP.JSON.decode/1`, now the one place + the wire's JSON is read, walks the bytes once (brackets outside strings, escapes honoured) and refuses a body nesting past **64 levels** with `-32600` "Request body nests deeper than 64 levels" (`400` over HTTP with the connection kept, since the body was read; the same object on stdio), building nothing: the worst body under the cap is refused in microseconds. Sixty-four: @@ -604,33 +657,33 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename - **A repeated key in the body is refused, on both transports.** Jason keeps the first of two equal keys; most other parsers keep the last, so a hop in front of this server that routed on the last `"name"` while this server executed the first was two sources of truth inside - one body — the disagreement the header–body match exists to close, reopened (found by a + one body: the disagreement the header–body match exists to close, reopened (found by a review lane driving the wire). Now a body that repeats a key in any object, at any depth, is `-32600` "Request body repeats a key: duplicate key \"name\"" (`400` over HTTP). The repeat is found in the decoded objects, not the bytes, so `"a"` and `"\u0061"` are one key as every decoder reads them. What the bounded decode costs against the decoder alone, by shape: a request-sized body 2 µs → 4 µs; a 1 MiB body that is one string 2.0 → 4.0 ms; key-dense - bodies of 600–900 KB 17–21 → 42–45 ms — the ordered-object decode and a second walk, + bodies of 600–900 KB 17–21 → 42–45 ms, the ordered-object decode and a second walk, the price of comparing keys after unescaping. **How to tell whether you are affected:** a client that nests a body past 64 levels or repeats a key in an object now gets `-32600` naming the cause; no field, method or capability moves. -### Fixed — the stdio loop under a hostile line or a host fault +### Fixed: the stdio loop under a hostile line or a host fault - Four things the threat model's rows claimed transport-wide and a review lane measured false on stdio, each red first. **A host fault ended the loop:** a dispatch function, catalog or hook that raised, threw or exited took `BeamMCP.Transport.Stdio.run/1` down with nothing written, and every request queued behind it was never answered; now it is answered `-32603 Internal error` with the request's id, the log line carries arities and never arguments, and the loop - goes on — as the HTTP transport has always answered `500`. **A line that was JSON but not an + goes on, as the HTTP transport has always answered `500`. **A line that was JSON but not an object got silence** (a string, a number, `null`, `true` were served as notifications); now `-32600 Expected a JSON object, got a string` and the like, as over HTTP. **A parse error - carried `data: inspect(reason)`** — the decoder's struct, with the client's own bytes in it, + carried `data: inspect(reason)`**: the decoder's struct, with the client's own bytes in it, up to `inspect`'s printable limit; now every refusal names its cause in the message and carries no data (`-32700 Parse error: body is not valid JSON`; a size refusal `-32600 Request line exceeds 1048576 bytes`, the code the HTTP transport's `413` carries, so one vector has - one code on every transport — it was `-32700` on stdio). **The tail of an over-long line was - read as the next frames** — refused once at the bound, then the remainder decoded as a fresh + one code on every transport; it was `-32700` on stdio). **The tail of an over-long line was + read as the next frames**: refused once at the bound, then the remainder decoded as a fresh message and refused again, and a test recorded that as observed; now the rest of the line is drained to its newline, byte by byte and never buffered, so the refusal is one line and the tail is nobody's frame. **A legacy `Content-Length` frame declaring more than the cap** was @@ -638,10 +691,10 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename frame and dispatched (a consumer-parse-back lane put a `tools/call` there and watched it answer); now the declared body is drained in chunks, never buffered, and the refusal names the frame: `-32600 Request frame exceeds 1048576 bytes`. **A header line of that block was read - with no bound** — a lane sent 64 MiB on one and it was read whole; now every header line is + with no bound**: a lane sent 64 MiB on one and it was read whole; now every header line is read under the line bound, and past it the block and its declared body are drained. **And the - line itself was held as a list of one-byte binaries** — 46–67 MiB of heap for a 1 MiB line - (measured); now the line is one off-heap binary and the loop retains none of it — and the + line itself was held as a list of one-byte binaries**: 46–67 MiB of heap for a 1 MiB line + (measured); now the line is one off-heap binary and the loop retains none of it; and the sampler that pinned it found a second forty: the legacy `Content-Length` check downcased the whole line to read a fifteen-byte prefix, 40 MiB of heap per 1 MiB line; now the prefix alone (0 MiB sampled). **And the line cap admits exactly 1 MiB:** a line of 1,048,576 bytes was @@ -649,7 +702,7 @@ API; `0.8.0` is a quiet minor in which no public entry is added, removed, rename frame; now the byte past the cap is the refusal on all three. A client that sent well-formed lines under the cap sees no change. -## [0.5.0] — 2026-09-16 +## [0.5.0] - 2026-09-16 Everything since `0.4.0`, grouped by the change that made it. **What moved on the wire in this release**, each in its own entry below: three resources methods, two prompts methods and one @@ -657,9 +710,9 @@ pagination cursor added, with the `resources` and `prompts` capabilities they ad `initialize` and `server/discover`; `server/discover`'s result filled to the `2026-07-28` `DiscoverResult` and a transport's advertised revisions narrowed to what it serves; `Mcp-Name` required on `resources/read` and `prompts/get` over HTTP; a tuple error reason encoded as an -array and `-32022`'s `data.requested` sent as a string; and one break on the wire — the +array and `-32022`'s `data.requested` sent as a string; and one break on the wire: the request `_meta` read at `params._meta` and refused at the top level. The sign-vocabulary break -is in the exported bytes, not a break on the wire — no 0.4.0 method carried a sign; the +is in the exported bytes, not a break on the wire; no 0.4.0 method carried a sign; the surface that now carries the bytes is new. The two catalog breaks are in the host contract. **The connectome surface moved nothing:** the five advertising methods were recorded on the core and through the HTTP transport before `BeamMCP.Connectome.Surface` existed @@ -668,10 +721,10 @@ same catalog to that recording and the catalog with the surface's entries to the plus exactly those entries, `server/discover` identical. No capability is claimed that the specification does not define; the census under `test/beam_mcp/boundary/` holds that. -### Added — the connectome on the wire, as the host chooses; multi-round-trip requests named out +### Added: the connectome on the wire, as the host chooses; multi-round-trip requests named out -- **`BeamMCP.Connectome.Surface`**: three read-only resources — `connectome://declared`, - `connectome://observed`, `connectome://diff` — for a host to put in its own catalog, and +- **`BeamMCP.Connectome.Surface`**: three read-only resources (`connectome://declared`, + `connectome://observed`, `connectome://diff`) for a host to put in its own catalog, and `call/2` for the one `:observe` tool a host that exposes tools only writes itself (the package holds no tool, by the will-not-implement page's entry 11; the spec to copy is in the moduledoc). Each answers the canonical bytes of the graph, **byte-identical to the file @@ -688,51 +741,51 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds **Nothing else on the wire moves:** a recording of `server/discover`, `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list`, on the core and through the HTTP transport, taken before this change and kept in the tree, is what the same catalog still - answers, and the catalog with the entries added answers that plus exactly the entries — + answers, and the catalog with the entries added answers that plus exactly the entries: `server/discover` identical as decoded JSON, no capability claimed (`connectome://` is a URI scheme, served by the resources primitive). Pagination of a large graph's bytes and subscriptions on `connectome://observed` are not here. - **No Cypher exporter, on purpose.** The canonical bytes load into Neo4j with APOC's JSON - loader — two statements over `nodes` and `edges`, shown in the Livebook and named on the - canonical page — and a fourth rendering would be one more surface no hash covers. + loader (two statements over `nodes` and `edges`, shown in the Livebook and named on the + canonical page), and a fourth rendering would be one more surface no hash covers. - **Multi-round-trip requests are out, by name** (`docs/will-not-implement.md`, entry 12; the `BeamMCP.Server` moduledoc): every request is answered completely or refused; the one `resultType` written is `"complete"`; `inputResponses` and `requestState` are read nowhere, and a request carrying them is served as if it carried neither. A census holds the names unread; a nine-case wire test holds the answer unchanged. -### Fixed — a tuple error reason from a host's dispatch is a tool error, not a crash +### Fixed: a tuple error reason from a host's dispatch is a tool error, not a crash -- A dispatch answering `{:error, {:missing, :window}}` — a tuple reason, the shape this - package's own refusals take — passed the tuple through to the JSON encoder, whose protocol +- A dispatch answering `{:error, {:missing, :window}}` (a tuple reason, the shape this + package's own refusals take) passed the tuple through to the JSON encoder, whose protocol has no implementation for one, and the request crashed. A tuple is now a JSON array on the wire (`["missing", "window"]`): in a tool error's `structuredContent.error` and its text, and in the `reason` a `resources/read` or `prompts/get` refusal carries as data. Found while pinning the connectome tool; any host that returned a tuple reason before got a crash, so no working client sees a change. -### Fixed — `Mcp-Name` on `resources/read` and `prompts/get` over HTTP +### Fixed: `Mcp-Name` on `resources/read` and `prompts/get` over HTTP -- The routing table requires `Mcp-Name` on three methods — `tools/call` (`params.name`), - `resources/read` (`params.uri`), `prompts/get` (`params.name`) — and the transport checked +- The routing table requires `Mcp-Name` on three methods, `tools/call` (`params.name`), + `resources/read` (`params.uri`), `prompts/get` (`params.name`), and the transport checked it on the first alone, from the day that was the only one served; the other two answered `200` without the header. Now all three require it and hold it to the body (`400`, `-32020` on a mismatch, as for `tools/call`). Found by a review lane; a client that already sends the header sees no change. -### Added — the prompts primitive, on the tools' own validation path +### Added: the prompts primitive, on the tools' own validation path - **`prompts/list` and `prompts/get`**, the two request methods the `2026-07-28` schema defines for prompts (`completion/complete` is the separate `completions` capability's and is not served; `notifications/prompts/list_changed` is not sent: `listChanged: false`). A catalog names prompts as `BeamMCP.PromptSpec` structs, each with `BeamMCP.PromptArgument`s, - in its existing `prompts` list — no new key — and renders them through a new optional + in its existing `prompts` list (no new key) and renders them through a new optional callback, `get_prompt/2`, required the moment a prompt is listed. **One validation path:** - a prompt's argument list is derived into a JSON Schema (`BeamMCP.PromptSpec.argument_schema/1` - — one `string` property per argument, `required` from the flags, nothing undeclared + a prompt's argument list is derived into a JSON Schema (`BeamMCP.PromptSpec.argument_schema/1`: + one `string` property per argument, `required` from the flags, nothing undeclared admitted) and validated by the tools validator; the arguments reach `get_prompt/2` keyed by the declared names, as a tool's reach its dispatch. A caller's argument name becomes an - atom on neither path — measured over 10,000 distinct keys through both, the tools path's + atom on neither path, measured over 10,000 distinct keys through both, the tools path's stated bound now a test. An unknown prompt, a missing required argument, an undeclared one and a reader's `{:error, reason}` are each `-32602` with data; a malformed reader answer is `-32603` by name. Messages are `user` or `assistant` with text content only, as for tools. @@ -743,13 +796,13 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds harness catalog's two text-only diagnostic prompts and leave the baseline; the `2026-07-28` suite row is 16 / 37 (12 / 37 after the resources entry below). - **How to tell whether you are affected:** if your catalog's `prompts` list carried anything - other than `%BeamMCP.PromptSpec{}` structs — a map with a `"name"`, which the declared - connectome read as a node name — `BeamMCP.Server.new/1` now refuses the catalog at startup, + other than `%BeamMCP.PromptSpec{}` structs (a map with a `"name"`, which the declared + connectome read as a node name), `BeamMCP.Server.new/1` now refuses the catalog at startup, naming the key; rewrite each as a `%BeamMCP.PromptSpec{name:}` and export `get_prompt/2`. An empty `prompts` list is unaffected. The declared connectome names a `%BeamMCP.PromptSpec{}` node as it named the map. -### Added — the pages ship in the package +### Added: the pages ship in the package - **`docs/` is in the Hex tarball.** The six pages the README links were absent from the package: the connectome vocabulary, the canonical bytes, the observed graph and the diff, @@ -757,7 +810,7 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds contract, new in this release. Where that showed: hex.pm's package page renders the README with each relative link resolved to the package preview (`repo.hex.pm/preview/beam_mcp//`), which serves the tarball's own files (a - probe of `mix.exs` there answers 200) — so every `docs/` link on 0.4.0's page was a 404 + probe of `mix.exs` there answers 200), so every `docs/` link on 0.4.0's page was a 404 (measured); a consumer with the package on disk (`deps/beam_mcp/` after `mix deps.get`) had the same dead paths. On hexdocs the links were already rewritten to the rendered pages and are unchanged. Now the pages are in the tarball at `docs/*.md`, so both the hex.pm render and the @@ -765,26 +818,26 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds the `files:` stanza: every file reachable by a relative link (inline, reference-style or an HTML `href`) from the README or CHANGELOG, every tracked page under `docs/`, and every ExDoc extra must be in what `mix hex.build` produces. The tarball grows by the six pages over - 0.4.0's 21 entries (the modules other entries of this release add — the reach module, the + 0.4.0's 21 entries (the modules other entries of this release add (the reach module, the resources primitive's two, the cursor codec, the prompts primitive's two and the connectome - surface, seven — ship beside them, and the count of them was taken from the built tarball at + surface, seven) ship beside them, and the count of them was taken from the built tarball at the release, not from this file's history; no byte count is stated: this file ships in the tarball). No other entry is added or removed by this change. -### Added — the resources primitive, and one pagination codec +### Added: the resources primitive, and one pagination codec - **`resources/list`, `resources/templates/list` and `resources/read`**, the three request methods the `2026-07-28` schema defines for resources (its `resources/subscribe` of earlier - revisions is gone — `subscriptions/listen` replaced it — and is not served; the capability + revisions is gone (`subscriptions/listen` replaced it) and is not served; the capability is advertised with `subscribe: false` and `listChanged: false`). A catalog names resources and templates as `BeamMCP.ResourceSpec` and `BeamMCP.ResourceTemplateSpec` structs in its - existing `resources` list — one list, two structs, no key twice, so `capabilities/0` gains - no key — and reads them through a new callback, `read_resource/1`, required the moment + existing `resources` list (one list, two structs, no key twice, so `capabilities/0` gains + no key) and reads them through a new callback, `read_resource/1`, required the moment either is listed. **One reader advertises and decides readability:** a `resources/read` uri is served only when the same list names it or a listed template matches it (RFC 6570 `{var}` one non-empty segment, `{+var}` across; no other expression is claimed, and a template carrying one, or a bare brace, is refused at - startup), else `Resource not found` with the uri as data before the reader runs — `-32602` + startup), else `Resource not found` with the uri as data before the reader runs: `-32602` under `2026-07-28`, which requires it, and `-32002` under `2025-11-25`, which named that code (the modern revision says clients SHOULD still accept it). A `params` that is not an object, on either list, is `-32602` by name. A reader's `{:error, reason}` is the same @@ -806,59 +859,59 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds remains. The page size is `BeamMCP.Server.new/1`'s `page_size:` (default 50). Both resource lists use it; `tools/list` does not yet. - **How to tell whether you are affected:** if your catalog's `resources` list carried - anything other than the two structs — a map with a `"uri"`, say, which the declared - connectome read as a node name — `BeamMCP.Server.new/1` now refuses the catalog at startup, + anything other than the two structs (a map with a `"uri"`, say, which the declared + connectome read as a node name), `BeamMCP.Server.new/1` now refuses the catalog at startup, naming the key; rewrite each entry as a `%BeamMCP.ResourceSpec{uri:, name:}` and export `read_resource/1`. A catalog with an empty `resources` list is unaffected. The declared connectome names a `%BeamMCP.ResourceSpec{}` node as it named the map; a template has no `uri` and is enumerated as an unreadable entry, not a node. -### Added — reachability, additive +### Added: reachability, additive - **Reachability queries over a connectome graph.** `BeamMCP.Connectome.Reach` answers four questions about a `BeamMCP.Connectome.Graph`, the declared one being the point: is there a path from an entry to an effect (`reachable?/4`); is there one that crosses none of a set of - gate nodes (`reachable_without/5`), and when there is, a **witness** — a + gate nodes (`reachable_without/5`), and when there is, a **witness**: a `BeamMCP.Connectome.Reach.Path` whose edges are the input graph's own, in order, so a reader checks it against the graph rather than trusting the package; does a gate dominate an effect from the entry set (`dominates?/4`, the removal definition itself); and which nodes - every path to an effect must cross (`mandatory_pass/3`, Lengauer–Tarjan — OTP's + every path to an effect must cross (`mandatory_pass/3`, Lengauer–Tarjan; OTP's `:digraph_utils` has no dominator function, so it is written here and held to `dominates?/4` by a property over every node of generated graphs). On OTP's `:digraph`, one private table per query deleted on every exit; **no new dependency**. Edge kinds and a hop limit are constraints; signs are not read. `max_edges:` is the one cap, refused by name; `all_paths/4` - is refused by name, always — enumerating paths is exponential and no cap makes it a + is refused by name, always: enumerating paths is exponential and no cap makes it a question this package answers. `docs/connectome-reach.md` is the contract, with the measured cost on the gate's 10 000-edge fixture (a search ~14–21 ms, dominators ~22–24 ms), recorded by every gate run (`bench/reach.exs`) and judged by no number. -- **`BeamMCP.Connectome.Edge.kinds/0`** — the edge vocabulary from one site. +- **`BeamMCP.Connectome.Edge.kinds/0`**: the edge vocabulary from one site. - **Nothing on the wire changes.** No method, field or capability is added. -### Added — the conformance harness, and a number a stranger can reproduce +### Added: the conformance harness, and a number a stranger can reproduce - **`tools/conformance.sh` runs the official MCP conformance suite** (`@modelcontextprotocol/ conformance` 0.2.0-alpha.11, pinned by exact version) against the HTTP transport with the harness catalog in `conformance/server.exs`, for each revision's frozen requirement set, and prints **two rows per revision** from the suite's own `checks.json`: suite totals (scored - scenarios passed / scored — the failures not hidden) and claimed-surface totals (over the + scenarios passed / scored, the failures not hidden) and claimed-surface totals (over the scenarios whose methods and tool names this package claims). Expected failures are baselined per revision with a reason word each; the suite exits 1 on a regression and on a stale entry. Measured 2026-09-15: `2026-07-28` 7 / 37 and 5 / 6; `2025-11-25` over HTTP 0 / 30 by design (HTTP serves `2026-07-28` only; `2025-11-25` lives on stdio, which the suite cannot drive). The README carries the rows with their provenance. Needs Node ≥ 22 and python3. -### Changed — BREAKING: the request `_meta` is read where the schema puts it, `params._meta` +### Changed (BREAKING): the request `_meta` is read where the schema puts it, `params._meta` - **A request's `_meta` lives in `params._meta`, and nowhere else.** `JSONRPCRequest` has no `_meta` property; `RequestParams` requires one in `2026-07-28`, with `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` - inside it. From `0.3.0` to `0.4.0` this package read the message's **top level** — and its - own tests sent it there — so a spec-following `2026-07-28` client over stdio had its version + inside it. From `0.3.0` to `0.4.0` this package read the message's **top level** (and its + own tests sent it there), so a spec-following `2026-07-28` client over stdio had its version go unread and was answered legacy-shaped while the server advertised modern; over HTTP the transport stamped a top-level `_meta` from the header before dispatch, which masked the position for every HTTP consumer (measured: the spec's shape, the old shape and no `_meta` at all each got a modern result). Now: the core reads `params._meta`; a top-level `_meta` is - refused with `-32602`, present with or without `params._meta` — the old position is not a + refused with `-32602`, present with or without `params._meta`; the old position is not a compatibility mode, because two accepted shapes would be permanent; a `params._meta` without `clientCapabilities` on a modern request is `-32602`; over HTTP the header is matched to `params._meta`, nothing is stamped, and a request whose `params._meta` is missing or lacks a @@ -867,33 +920,33 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds served as if bare over stdio, and refused with 400 over HTTP where every POST is checked. **How to tell whether you are affected:** if your client puts `"_meta"` beside `"method"` in the request object, it now gets `-32602 Invalid params: _meta belongs in params._meta`; move - it inside `"params"` — `{"method": "tools/call", "params": {"name": ..., "arguments": ..., - "_meta": {...}}}` — and, for `2026-07-28`, include `clientCapabilities` in it. Over HTTP, a + it inside `"params"` (`{"method": "tools/call", "params": {"name": ..., "arguments": ..., + "_meta": {...}}}`) and, for `2026-07-28`, include `clientCapabilities` in it. Over HTTP, a client that sent no `_meta` and relied on the header now gets `-32602` with 400; send `params._meta` naming the same version as the header. Found by the official conformance suite's `server-stateless` scenario and by reading the schema it cites. -### Changed — BREAKING: the sign vocabulary — `:unknown` is `:unset`, `:ungoverned` is added, and `schema_version` is `2` +### Changed (BREAKING): the sign vocabulary: `:unknown` is `:unset`, `:ungoverned` is added, and `schema_version` is `2` - **The one sign the package writes is `:unset`, not `:unknown`.** It means exactly this: no sign has been supplied to this package. It does not mean no policy exists, spoke or was - computed — a host whose authority plane denied an edge, where that verdict never reached + computed: a host whose authority plane denied an edge, where that verdict never reached this package, gets `:unset` on that edge, and a graph glossing that as "no policy has spoken" (the 0.4.0 wording) would be wrong about the world. The graph is what a consumer signs, so the word had to be the narrow true one. `BeamMCP.Connectome.Edge.check/1` refuses `:unknown` by name. - **`:ungoverned` is a fifth value, a consumer's:** a consumer looked and no gate applies to - this edge. The package never treats it as suppression — the diff records the edge exactly as + this edge. The package never treats it as suppression; the diff records the edge exactly as any other (a sign appears in the diff record only in a changed-sign entry; every sign is in the graph's bytes), and a census holds that no code line filters, hides or downgrades an edge by its sign. The bytes carry no field saying which consumer wrote a sign or when. The sign is orthogonal to drift: an observed edge nobody declared is `observed_but_undeclared` whatever its sign. - **Changed-sign is two authorities disagreeing.** A label in both graphs is `changed_sign` - when both signs are supplied — neither `:unset` — and they differ; held over all + when both signs are supplied (neither `:unset`) and they differ; held over all twenty-five pairs. `:ungoverned` against `:deny` is a finding; `:unset` against `:ungoverned` is not; `:unset` on both sides never is. -- **Two coverage counts added, `declared_sign_only` and `observed_sign_only`** — labels in +- **Two coverage counts added, `declared_sign_only` and `observed_sign_only`**: labels in both graphs with a sign supplied on one side and `:unset` on the other, one count per direction because the two directions are different facts (a sign on the observed side only means an authority spoke during the run about an edge nobody signed at configuration @@ -906,8 +959,8 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds (`docs/connectome-canonical.md`, *Versions*). `BeamMCP.Connectome.Graph.new/1` refuses any version but `2`. - **How to tell whether you are affected:** if any code of yours pattern-matches, compares - against, or *writes* `:unknown` into an edge's `sign` — a comparison never holds again - rather than failing; a write is refused by `BeamMCP.Connectome.Graph.new/1` — reads + against, or *writes* `:unknown` into an edge's `sign` (a comparison never holds again + rather than failing; a write is refused by `BeamMCP.Connectome.Graph.new/1`), reads `"sign":"unknown"` out of the bytes, enumerates the sign strings as a closed set (it now meets `"unset"` and `"ungoverned"`), builds a `%BeamMCP.Connectome.Graph{}` with `schema_version: 1`, or checks a diff record for `"schema_version":1`, it breaks; replace @@ -916,11 +969,11 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds from a stored 0.4.0 one: its `coverage` gained the two counts and its version moved. On the graph's bytes no other value, field or order moved. -### Changed — on the wire, in `server/discover` and in what a transport advertises +### Changed: on the wire, in `server/discover` and in what a transport advertises - **`server/discover` returns the `2026-07-28` `DiscoverResult` in full.** The schema requires `cacheScope`, `capabilities`, `resultType`, `supportedVersions` and `ttlMs`; the result was - undecorated — a probe shortcut carried since `0.3.0`, called a known gap in the README — and + undecorated (a probe shortcut carried since `0.3.0`, called a known gap in the README), and a client reading it had grounds to classify the server as legacy. Now: `supportedVersions` (the field was named `protocolVersions`), `resultType: "complete"`, `ttlMs: 0` and `cacheScope: "private"` (nothing is cached; the non-permissive default), and the server's @@ -930,18 +983,18 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds - **A transport advertises only the revisions it serves.** `BeamMCP.Server.new/1` takes `supported_versions:`; the HTTP transport, which refuses every revision but `2026-07-28` on every POST, passes exactly that, so its `server/discover` says `["2026-07-28"]` and its - `-32022` error lists the same — it had listed `2025-11-25` while refusing it. A revision not + `-32022` error lists the same; it had listed `2025-11-25` while refusing it. A revision not advertised is not served either. Dual-era is a stdio fact; stdio advertises both, as before. - **`-32022`'s `data.requested` is a string over HTTP**, the version the client asked for (the first value refused when several were sent), as the schema says; it was a list. -### Added — the boundary, written down and held to the tests +### Added: the boundary, written down and held to the tests - **`docs/will-not-implement.md`**: eleven things this package will never do (a twelfth, the - multi-round-trip request, joined them in the connectome entry above) — populate a sign, hold a + multi-round-trip request, joined them in the connectome entry above): populate a sign, hold a key, make a signature, decide authority, put a payload byte in the observed graph, claim a capability the specification does not define, issue or honour a session identifier, carry - OAuth, be a client, enumerate all paths or match motifs, hold a tool, a domain or a catalog — + OAuth, be a client, enumerate all paths or match motifs, hold a tool, a domain or a catalog; each with its reason in a line and the test that enforces it, by path and by name. The tests are the proof; the page is the contract. The README's *Deliberately out* paragraph points at it. @@ -958,14 +1011,14 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds atom built at runtime, by name), and **one over the artefact itself**: `:xref` over the compiled beams pins exactly the modules the package calls, the functions it calls on the modules that could reach code, secrets, the OS or another node, and the one atom it makes from - a binary — a call under any spelling resolves to the same edge, so a new library, evaluator, + a binary; a call under any spelling resolves to the same edge, so a new library, evaluator, socket, shell, remote spawn, key store or environment read fails until it is named. One reader over `lib/**/*.ex` for the text censuses; the tool-construction census reads the compiled forms, where the struct's atom may occur only in a map pattern. Each was shown red by a planted violation. `Plug.Crypto`, in the lock file through `plug`, is barred by name. - **A README pin repaired, test-only**: "the observed graph carries edge identity only, never a payload byte" sent its marker to a tool with no schema, where argument normalisation - dropped it before dispatch — the assertion held nothing. It now uses the catalog whose tool + dropped it before dispatch; the assertion held nothing. It now uses the catalog whose tool declares the key and requires the dispatch to have seen the marker first. - **The page and the tests are one population**: `test/beam_mcp/will_not_implement_test.exs` fails when the page cites a test that does not exist by path or by name, when a test marked @@ -973,7 +1026,7 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds - **Nothing on the wire changes.** No method, field or capability is added; the capability census pins that none is. -### Fixed — test suite only +### Fixed: test suite only - The Livebook exports fixture ran its collector without a lock; two async test modules exporting at once saw each other's `fx` calls and exported doubled weights, a one-in-many @@ -981,16 +1034,16 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds The collector run is serialised across the node (`:global.trans/4`, the requester being the caller). Nothing shipped changes. -## [0.4.0] — 2026-09-14 +## [0.4.0] - 2026-09-14 -### Added — the connectome, additive +### Added: the connectome, additive - **The connectome vocabulary and data model.** `docs/connectome.md` defines the words: a connectome is the wiring diagram of a composed MCP system, built once from what is declared and once from what ran. `BeamMCP.Connectome.Node`, `BeamMCP.Connectome.Edge` and `BeamMCP.Connectome.Graph` carry it: a node has a structural id derived by one function from the identity the host supplies; an edge carries from, to, kind, provenance, an optional weight, and a sign slot. **The package writes - `:unknown` into that slot and nothing else** — it populates no sign, signs no finding, holds + `:unknown` into that slot and nothing else**: it populates no sign, signs no finding, holds no key, decides no authority; that is the host's, and a census test over `lib/` holds it. `BeamMCP.Connectome.Graph.new/1` validates every struct it is handed, by field, and corrects nothing. - **The declared connectome.** `BeamMCP.Connectome.Declared.build/1` reads three sources and @@ -998,19 +1051,19 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds their beams (OTP's `:xref`, so a module whose only use is at a macro's expansion site produces no edge, and the calls a macro body makes at expansion time are filed as expansion calls, not edges), and a grouping of modules at the `:boundary` level. Beside the graph it returns the completeness - bound — every dynamic-dispatch site, callee outside the scope, unreadable catalog entry, tool + bound: every dynamic-dispatch site, callee outside the scope, unreadable catalog entry, tool without a module, and module without a beam or debug information, enumerated and never summarised to a count. What the compiled code cannot show is stated in the moduledoc: a module handed as data to a dispatcher outside the scope, and calls to the runtime's built-ins. A catalog the package's own contract check refuses is refused by the builder for the same reason, and never read. OTP's `tools` application, which carries `:xref`, is - declared optional in the `.app` file — in the one spelling Mix honours, `tools: :optional` - inside `extra_applications`, with a test that reads the `.app` the build writes — so a release + declared optional in the `.app` file (in the one spelling Mix honours, `tools: :optional` + inside `extra_applications`, with a test that reads the `.app` the build writes), so a release without it still boots and the builder refuses by name. - **Canonical bytes, a hash, and exports.** `BeamMCP.Connectome.Canonical.encode/1` writes the - declared form of a graph as canonical JSON — schema version first, nodes by id, edges by key, + declared form of a graph as canonical JSON (schema version first, nodes by id, edges by key, every other object's keys in RFC 8785 order, strings NFC-normalised, every atom a string under - the name of its field, no weight — and `hash/1` is SHA-256 over those bytes. The byte layout is + the name of its field, no weight), and `hash/1` is SHA-256 over those bytes. The byte layout is specified in full in `docs/connectome-canonical.md`, with a worked example whose hash reproduces with `sha256sum` alone, so a verifier can be written without importing this package. Weights travel in a separate sidecar that is never hashed. `to_dot/1`, `to_graphml/1` and `to_json/1` @@ -1019,8 +1072,8 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds that is not valid UTF-8; and, by `to_graphml/1` alone, a character XML 1.0 cannot carry. - **The observed connectome: a span on the dispatch path, a collector, a guarded tracer.** `BeamMCP.Server` emits `[:beam_mcp, :dispatch, :start | :stop | :exception]` in - `:telemetry.span/3`'s shape around the host's dispatch function — metadata `server_name` and `tool`, - plus `outcome` on stop; no argument, result or header bytes — and `:telemetry` is a new + `:telemetry.span/3`'s shape around the host's dispatch function (metadata `server_name` and `tool`, + plus `outcome` on stop; no argument, result or header bytes), and `:telemetry` is a new required dependency (Apache-2.0, no dependencies of its own). `BeamMCP.Connectome.Observed` is a process the host adds to its own tree: it turns every attempt into one row keyed by edge identity, bounded by distinct edges and never by calls, written from the caller's @@ -1031,30 +1084,30 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds `:arity` flag and without reading a message. `docs/connectome-observed.md` is the contract. - **The diff engine.** `BeamMCP.Connectome.Diff.run/3` takes the declared and the observed graph and a window the consumer supplies, and puts every edge of either in exactly one of four classes - — declared-and-observed, declared-never-observed (dead authority), observed-but-undeclared (a - drift finding), changed-sign (both signs supplied and different) — with a coverage bound as + (declared-and-observed; declared-never-observed, dead authority; observed-but-undeclared, a + drift finding; changed-sign, both signs supplied and different), with a coverage bound as eight counts the consumer divides, among them completeness with the connectomics roles kept (observed edges between declared parts, over what ran) and its dual, endpoint coverage. The diff is a set difference over edge labels (from, to, kind), never an isomorphism, and says why. Its record has canonical bytes through - `BeamMCP.Connectome.Canonical.encode_value/1` — the label grammar of a node's labels object, - applied to a record on its own, so a consumer's verifier reads it unchanged — and + `BeamMCP.Connectome.Canonical.encode_value/1` (the label grammar of a node's labels object, + applied to a record on its own, so a consumer's verifier reads it unchanged), and `docs/connectome-diff.md` is the contract, with a worked example that reproduces with `sha256sum`. -- **`BeamMCP.Stacktrace.arities/1`** — the one rewrite that turns a stacktrace's argument lists +- **`BeamMCP.Stacktrace.arities/1`**: the one rewrite that turns a stacktrace's argument lists into arities and its locations into what the compiler writes, used by the `:exception` event - and by the HTTP transport's fault log, which had logged a dispatch's stacktrace untouched — + and by the HTTP transport's fault log, which had logged a dispatch's stacktrace untouched: a caller's arguments in the host's log. -- **`BeamMCP.Server.new/1` refuses every wrong or unknown option by name at construction** — - `server_name`, `dispatch`, `dispatch_opts`, `tools_ttl_ms`, `tools_cache_scope`, a typo — +- **`BeamMCP.Server.new/1` refuses every wrong or unknown option by name at construction** + (`server_name`, `dispatch`, `dispatch_opts`, `tools_ttl_ms`, `tools_cache_scope`, a typo), the way it already refused a malformed catalog; before, a non-string `server_name` was held and raised inside the connectome's id derivation at snapshot time. - **The gate reads the branch's own commit messages** for attribution trailers, session links, - board identifiers and consumer names — offline, over `origin/main..HEAD`; a pull request's body + board identifiers and consumer names: offline, over `origin/main..HEAD`; a pull request's body is read by a person before merge. - **No MCP capability is claimed.** Neither protocol revision this package targets defines a topology or declared-reachability primitive, and none is invented. Nothing on the wire changes. -### Added — the Livebook, and the instruments the release is measured with +### Added: the Livebook, and the instruments the release is measured with - **A Livebook renders the connectome from its JSON export alone.** `livebooks/connectome.livemd` draws the declared graph, the observed graph with its sidecar @@ -1064,9 +1117,9 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds held by a test, byte for byte, to what the package produces from its fixtures today; `tools/livebook_eval.exs` evaluates the cells outside Livebook. - **Three gate steps, and a population that cannot be forgotten.** `bench` measures the - collector's per-call overhead against a ceiling of 1.5 µs per `tools/call` — the owner's + collector's per-call overhead against a ceiling of 1.5 µs per `tools/call` (the owner's number, with its reasoning in `bench/overhead.exs`; a ceiling on an optional, off-by-default - feature, not a performance promise — and records the diff engine's cost on a 10 000-edge + feature, not a performance promise), and records the diff engine's cost on a 10 000-edge fixture, for which no threshold is set. `properties` runs the property tests alone at a thousand generations each (`PROPERTY_RUNS`, read by the test helper). `instruments` parses every tracked shell and Python file. And the format step's population is the tracked set, @@ -1078,24 +1131,24 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds one module's functions but were another's, or a hook's, are qualified. ExDoc groups the modules by namespace. -### Changed — BREAKING, and it breaks a host contract rather than the wire +### Changed: BREAKING, and it breaks a host contract rather than the wire - **`BeamMCP.ToolCatalog` is replaced by `BeamMCP.Catalog`, and `all/0` by `capabilities/0`.** Every host implementing a catalog must change. While this package is `0.x` a break lands at - the **minor** position, so this is `0.4.0` and `~> 0.3.0` — the requirement the README - recommends — already excludes it. No consumer is carried across by a routine + the **minor** position, so this is `0.4.0` and `~> 0.3.0` (the requirement the README + recommends) already excludes it. No consumer is carried across by a routine `mix deps.update`; that is what the tight pin is for. `capabilities/0` returns `%{tools: [ToolSpec.t()], resources: [], prompts: []}`. `resources` and `prompts` are **required and may be empty**. Nothing reads them yet. They exist so that serving resources and prompts later adds a reader rather than changing this contract a second - time — the break is taken once, now, before those slices exist. + time; the break is taken once, now, before those slices exist. **The callback is renamed, not just re-typed.** Keeping `all/0` while changing its return from a list to a map would compile against every existing host and fail at the first request with a `BadMapError`. Renaming makes the break arrive at compile time as an unimplemented callback. - The option is `:catalog`, not `:tool_catalog` — a catalog carrying resources and prompts is + The option is `:catalog`, not `:tool_catalog`: a catalog carrying resources and prompts is not a tool catalog, and renaming it in the same break costs less than a second one later. **Migration:** @@ -1111,7 +1164,7 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds ``` - **A malformed catalog is refused by `BeamMCP.Server.new/1`**, at startup, with a message naming - what is wrong — an absent key, a non-list `:tools`, an entry that is not a `%ToolSpec{}`, a + what is wrong: an absent key, a non-list `:tools`, an entry that is not a `%ToolSpec{}`, a `capabilities/0` that does not return a map, or a module that does not export it. The HTTP transport's `init` callback checks only that the hook is exported, deliberately: under Plug's default initialisation it runs at the host's **compile** time, where calling a catalog that @@ -1120,8 +1173,8 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds ### Added - **`:authorize_body`, an optional post-read authorization hook.** `authorize/1` runs before the - request body is read — which is what lets it refuse an unauthenticated caller without - buffering megabytes on their behalf — and the cost of that position is that it cannot see the + request body is read (which is what lets it refuse an unauthenticated caller without + buffering megabytes on their behalf), and the cost of that position is that it cannot see the body. Body-signature authentication was therefore not merely awkward through it but structurally impossible: there was no argument through which the bytes arrived. @@ -1133,7 +1186,7 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds **Additive and optional.** Absent, it is skipped and nothing changes; `authorize/1`'s arity, position and semantics are untouched, so no existing host is affected. Present, it must be a - 2-arity function or `init/1` raises — a wrong arity is a startup failure, not a per-request + 2-arity function or `init/1` raises; a wrong arity is a startup failure, not a per-request one. A refusal answers `403` and a raising hook `500`, both opaque: the reason goes to the log, never to the caller, so a client cannot distinguish "no signature" from "bad signature". A post-read refusal does **not** carry `connection: close`, because by then the body is read @@ -1142,26 +1195,26 @@ specification does not define; the census under `test/beam_mcp/boundary/` holds **This package performs no cryptography.** The option is named `:authorize_body` rather than `:verify_signature` because verifying is the host's work; making it possible is this module's. -## [0.3.1] — 2026-09-08 +## [0.3.1] - 2026-09-08 Five defects in the HTTP transport. Four were found by slice 002's review lanes and filed rather than fixed at the time; the fifth was found by this slice's own review and fixed here rather than tagged around, because it is unauthenticated and attacker-reachable. No wire break: `~> 0.3.0` admits this release and still excludes the next one, measured with Elixir's own `Version` module rather than recalled. The whole row, not -an extract of it — an abridged quotation is not a quotation: +an extract of it; an abridged quotation is not a quotation: requirement 0.1.0 0.2.0 0.3.0 0.3.1 0.4.0 1.0.0 ~> 0.3.0 false false true true false false Written by the run that produced it, in -`slices/003-release-0-3-1/logs/measure-version-requirement.txt` **in the repository — slice +`slices/003-release-0-3-1/logs/measure-version-requirement.txt` **in the repository; slice records are not shipped in the package**, and the same is true of every `slices/…` path below. ### Fixed - **An `x-mcp-header` annotation on a non-primitive parameter is the host's fault.** The revision allows the annotation only on `integer`, `string` and `boolean`, and - `value_matches?/2` assumed that MUST rather than checking it — so an annotated `number`, + `value_matches?/2` assumed that MUST rather than checking it, so an annotated `number`, `object` or `array` could never match any header. Omitting the header was refused as *"required: the body carries a value to mirror"* and supplying one as *"does not match"*: the tool was advertised through `tools/list`, was permanently uncallable, and the `400` blamed the @@ -1171,7 +1224,7 @@ records are not shipped in the package**, and the same is true of every `slices/ - **Colliding `x-mcp-header` names are refused rather than silently collapsed.** The revision requires the values to be case-insensitively unique. The annotation set was accumulated into a - map keyed by the case-folded name, so `Dup` and `DUP` collapsed to one entry — `Map.put` lost + map keyed by the case-folded name, so `Dup` and `DUP` collapsed to one entry: `Map.put` lost the sibling and a nested annotation could overwrite an outer one. One annotated property was then never checked at all, and which one survived depended on map iteration order. The set is now a list keyed by nothing, and a collision is refused as a host fault like the above. @@ -1185,18 +1238,18 @@ records are not shipped in the package**, and the same is true of every `slices/ - **A header value that is not valid UTF-8 is refused, not reflected.** `MCP-Protocol-Version` carrying invalid UTF-8 was echoed back in the refusal's `data.requested`, so `Jason.encode!` - raised inside `send_json/3` — outside every inner rescue — and the caller's own `400` became a + raised inside `send_json/3` (outside every inner rescue) and the caller's own `400` became a `500` with an error-level stacktrace in the host's log. Unauthenticated and attacker-reachable: no credential is needed to send a header. The refusal now happens at the read, in - `header_values/2`, which every header read in the transport routes through — so it covers + `header_values/2`, which every header read in the transport routes through, so it covers `Origin`, `Mcp-Method`, `Mcp-Name` and every `Mcp-Param-{Name}` as well, and a header read added later inherits it. The offending bytes are named as a header, never reproduced in the response. - **A refusal issued before the request body is read carries `connection: close`.** Only the - `413` did. The other six pre-read refusal sites — the `Origin` `403`, the `405`, + `413` did. The other six pre-read refusal sites (the `Origin` `403`, the `405`, `authorize/1`'s `500`, its `403` and its contract-violation `403`, and the body read's own - `400` — answered on a connection whose body was still on the wire and said nothing about it, + `400`) answered on a connection whose body was still on the wire and said nothing about it, so the adapter read that body anyway on behalf of a caller already refused (`Bandit` drains up to 8 MB, waiting up to its read timeout), and past that limit dropped the connection with nothing said to the client. All seven now answer through one path, so a step added in front @@ -1211,13 +1264,13 @@ Recorded rather than fixed, with the measurement, in `slices/003-release-0-3-1/F - **`read_body_bounded/1`'s `{:error, reason}` `400` has no test**, and now carries the new close behaviour untested with it. -## [0.3.0] — 2026-09-07 +## [0.3.0] - 2026-09-07 -### Added — stateless Streamable HTTP transport +### Added: stateless Streamable HTTP transport `BeamMCP.Transport.HTTP` is a `Plug` serving `2026-07-28` at one endpoint. Every request stands alone: **no sessions, no `Mcp-Session-Id`, no SSE resumability**, all three removed from the -transport in that revision. `handle_message/2` gains no clause — HTTP is a second caller of the +transport in that revision. `handle_message/2` gains no clause; HTTP is a second caller of the existing core. `plug` and `bandit` are **optional** dependencies, so a host using only stdio does not pull an @@ -1233,7 +1286,7 @@ HTTP server into its tree. allowed_origins: ["https://app.example.com"]}, # required, no default port: 4000, ip: {127, 0, 0, 1}) -This package cannot decide who may call your tools — it has no view of your identity model, and +This package cannot decide who may call your tools: it has no view of your identity model, and deciding for you would be claiming something it cannot keep. But a Plug that serves `tools/call` to anyone who can reach the port is a confused-deputy surface, and "the host should have authenticated" is documentation rather than a control. **A required argument with no default is @@ -1252,12 +1305,12 @@ DNS rebinding. `:any` is available and must be chosen deliberately. | `Mcp-Method` on every request (**not** on notifications, which the revision leaves undefined) | missing or mismatched -> `400`, `-32020` | | `Mcp-Name` on `tools/call` | missing or mismatched -> `400`, `-32020` | | `Mcp-Param-{Name}` for every parameter the tool's schema marks `x-mcp-header` | mismatched, or omitted while the body carries the value -> `400`, `-32020` | -| an encoded header value is decoded before comparison | `=?base64?…?=`, on `Mcp-Name` and `Mcp-Param-{Name}` **only** — the two headers the specification scopes it to | -| `initialize`, `notifications/initialized`, `ping` | `404`, `-32601` — deleted by this revision | +| an encoded header value is decoded before comparison | `=?base64?…?=`, on `Mcp-Name` and `Mcp-Param-{Name}` **only**: the two headers the specification scopes it to | +| `initialize`, `notifications/initialized`, `ping` | `404`, `-32601`, deleted by this revision | | unsupported version | `400`, `-32022` | | invalid `Origin` | `403` | | unknown **method** | `404`, `-32601` | -| unknown **tool** | `200` with `-32601` in the body — a live endpoint, a bad argument | +| unknown **tool** | `200` with `-32601` in the body: a live endpoint, a bad argument | | non-POST (a SHOULD, not a MUST) | `405` with `Allow: POST` | The mirrored-parameter population is derived from **the tool's `inputSchema`**, not from the @@ -1267,39 +1320,39 @@ define what gets checked; and `x-mcp-header` carries a header **name portion** p property path that may be nested, so the mapping cannot be recovered by lowercasing a header suffix into a top-level argument key. -**Every header is validated in all of its values, not the first.** A duplicated header — a -satisfying value followed by a hostile one — is the same smuggling the MUST above exists to +**Every header is validated in all of its values, not the first.** A duplicated header (a +satisfying value followed by a hostile one) is the same smuggling the MUST above exists to stop, and the first version of this transport read only the first value of four of them. There is now one read path, `header_values/2`, and one mutant per header proving each is pinned -(`slices/002-streamable-http/logs/mutation.md`, in the repository — slice records are not shipped in the package). +(`slices/002-streamable-http/logs/mutation.md`, in the repository; slice records are not shipped in the package). `Mcp-Method` and `Mcp-Name` are validated against the body because the specification says why: *"a load balancer routing on the header value while the MCP server executes based on the body value."* An earlier draft of this table omitted both while calling itself a list of MUSTs, and a -reviewer demonstrated the consequence — `Mcp-Method: tools/list` with a `tools/call` body +reviewer demonstrated the consequence: `Mcp-Method: tools/list` with a `tools/call` body returned `200` and reached dispatch. -**What this closes.** On stdio a `tools/call` with no handshake and no `_meta` is served — +**What this closes.** On stdio a `tools/call` with no handshake and no `_meta` is served, defensible there, because whoever can write to that transport already has the host's privileges. Over HTTP that argument does not hold, and the specification removes the case: the version header is mandatory, so a request with no era established is *malformed* and refused as a protocol matter rather than a policy choice. -### Added — `ttlMs` and `cacheScope` on `tools/list` +### Added: `ttlMs` and `cacheScope` on `tools/list` `2026-07-28` requires both via `CacheableResult`. No release before this one emitted them. Neither is the package's to invent: `ttlMs` is a freshness hint about a catalog the host owns, -and `cacheScope` is a disclosure decision — `"public"` lets shared intermediaries cache a tool +and `cacheScope` is a disclosure decision: `"public"` lets shared intermediaries cache a tool list, and a tool list can be sensitive. Both are host-supplied through `tools_ttl_ms:` and `tools_cache_scope:`, and **the default is the non-permissive one** (`0` and `"private"`). A package that picked the permissive default on a host's behalf would be making a disclosure decision it cannot keep. -### Known limitation — `authorize/1` cannot see the request body +### Known limitation: `authorize/1` cannot see the request body It runs **before** the body is read and returns `:ok | {:error, reason}`, with no way to hand back -the `conn` it read from, so **body-signature authentication — an HMAC over the payload — is not +the `conn` it read from, so **body-signature authentication (an HMAC over the payload) is not possible in it**. This does not fail as an error: a small request appears to work because the body is already in the adapter's buffer, and a larger one hangs until the server's read timeout and returns `408` with the connection dead (measured: 119 bytes `200`; 16 KiB and 200 KiB both `408` @@ -1308,20 +1361,20 @@ written down. Today: authenticate in a plug in front of this one that reads the body and re-supplies it, or decide in `dispatch/3`, which is handed the decoded arguments. **This is an open design question, -not a final shape.** The likely answer is two hooks — `authorize/1` staying as the cheap pre-read -gate on headers, origin and peer, plus an optional post-read hook for body signatures — and that +not a final shape.** The likely answer is two hooks (`authorize/1` staying as the cheap pre-read +gate on headers, origin and peer, plus an optional post-read hook for body signatures), and that belongs in its own release rather than in one that is otherwise finished. Designing an authentication contract under release pressure is how the wrong one ships permanently. -### Changed — the recommended dependency requirement +### Changed: the recommended dependency requirement `README.md` now recommends `{:beam_mcp, "~> 0.3.0"}`. `~> 0.3` admits `0.4.0`, and this package documents wire breaks at the **minor** position while it is `0.x`. -### Changed — a host's exception no longer chooses the HTTP status +### Changed: a host's exception no longer chooses the HTTP status **Read this if a host tool, `authorize/1` or `tool_catalog` raises an exception carrying a -`:plug_status`** — `Plug.BadRequestError`, or Ecto's `NoResultsError` at 404, for instance. In +`:plug_status`**: `Plug.BadRequestError`, or Ecto's `NoResultsError` at 404, for instance. In `0.2.0` there was no HTTP transport, so this is new behaviour rather than changed behaviour, but it changed twice during this release and the second shape is what ships: @@ -1330,14 +1383,14 @@ it changed twice during this release and the second shape is what ships: now -> HTTP 500, {"jsonrpc":"2.0","id":,"error":{"code":-32603,...}} -An exception carrying a status means the **server** is signalling — Bandit raises +An exception carrying a status means the **server** is signalling: Bandit raises `Bandit.HTTPError` for a malformed transfer coding and its pipeline turns that into the response. An exception out of **host** code carries no such authority however it is annotated, and letting it through dropped the envelope on a path the protocol requires one, with no `id` to correlate and no body to parse. -The rule is applied at every point host code runs in the request path — `authorize/1`, -`BeamMCP.ToolCatalog.fetch/2` and `BeamMCP.Server.handle_message/2` — and that population is +The rule is applied at every point host code runs in the request path (`authorize/1`, +`BeamMCP.ToolCatalog.fetch/2` and `BeamMCP.Server.handle_message/2`), and that population is derived by grep rather than listed, because listing it is how the first cut of this fix covered one of the three and shipped a comment claiming all of them. @@ -1352,18 +1405,18 @@ the common case.** Measured: `authorize/1` runs before the body is read, so there is genuinely no id to echo. The malformed- spec case is different and is an inconsistency rather than a necessity: the spec is read at `annotations(spec.input_schema)`, which sits outside `host_call/1`, so a host DATA fault escapes -to `call/2`'s rescue where the id is not known — while a host RAISE two lines earlier is caught +to `call/2`'s rescue where the id is not known, while a host RAISE two lines earlier is caught and answered with it. Recorded rather than fixed here, because moving that read inside `host_call/1` changes which rescue answers and wants its own red. -### Added — `BeamMCP.ToolCatalog.fetch/2` is public API +### Added: `BeamMCP.ToolCatalog.fetch/2` is public API One lookup answers "which tool does this name mean" for both the core and the HTTP transport's header validation. Two lookups would be two answers, which is the disagreement the mirrored-header mechanism exists to prevent. Its `@spec` says `{:ok, t} | :error` and three host-authored catalog shapes raise instead; that is filed, not fixed here. -### Fixed — two failure modes found by measuring rather than by reasoning +### Fixed: two failure modes found by measuring rather than by reasoning - **A failure in the host's dispatch answered with an empty `500`.** It now answers `-32603 Internal error` **carrying no detail**; the reason still reaches the logger, where the @@ -1371,18 +1424,18 @@ shapes raise instead; that is filed, not fixed here. host's internals. The first fix used `rescue`, which catches raises only. A reviewer showed `throw` and `exit` - still producing the bare empty `500` this entry claimed had been eliminated — and `exit` is + still producing the bare empty `500` this entry claimed had been eliminated, and `exit` is the shape that matters most, because **a `GenServer.call` timeout exits**, which is what a host calling a backend hits first. Now `catch`, covering all three. -- **`BeamMCP.Transport.Stdio` was documented.** It carried `@moduledoc false` — inherited from - the tree it was extracted from, where it was internal — while `README.md` documents `run/1` as +- **`BeamMCP.Transport.Stdio` was documented.** It carried `@moduledoc false`, inherited from + the tree it was extracted from, where it was internal, while `README.md` documents `run/1` as the entry point. Left alone, `0.3.0` would have published hexdocs in which the stdio transport is absent and the new HTTP transport beside it renders, which reads as deliberate. **This is the second instance of one defect: `BeamMCP.Server` shipped hidden in `0.1.0`.** The - entry recording that one also recorded why nothing caught it — "a hidden module is not a - compile warning and the gate does not run `mix docs`" — and the gate still did not, for two + entry recording that one also recorded why nothing caught it: "a hidden module is not a + compile warning and the gate does not run `mix docs`"; and the gate still did not, for two more releases. So `tools/gate.sh` now has a `docs` step, and it reads `mix docs`'s **output** rather than its exit code, because `mix docs` exits `0` on a warning: @@ -1393,7 +1446,7 @@ shapes raise instead; that is filed, not fixed here. mechanism never is what the step is for. - **The `403` for a refused caller carried the host's refusal reason.** `authorize/1` returns - `{:error, term}`, and that term was `inspect`ed into the response body — on the one branch + `{:error, term}`, and that term was `inspect`ed into the response body, on the one branch that is by definition unauthenticated. A reviewer recovered a planted bearer token and a database URL from it. The reason now goes to the log; the caller is told only `Forbidden`. @@ -1403,15 +1456,15 @@ shapes raise instead; that is filed, not fixed here. - **Five error paths poisoned the next request on a keep-alive connection**, and the first diagnosis of this was wrong in a way worth recording. It was reported here as a `413` - problem. A reviewer could not reproduce it on `413` — and was right, because `413` already + problem. A reviewer could not reproduce it on `413`, and was right, because `413` already carried the updated connection out. The real defect was one level up: `with/else` clauses cannot see bindings made inside the `with`, so **every refusal after the body was read answered on the pre-read connection**. Bandit then framed the next request's bytes as this one's unread body, and the following request hung. Affected: missing protocol header, header mismatch, unsupported version, invalid JSON and - non-object JSON. It is **size-dependent** — a few dozen bytes arrive in a single adapter read - and look correct — which is why every test and every row of the first resilience table passed + non-object JSON. It is **size-dependent**: a few dozen bytes arrive in a single adapter read + and look correct, which is why every test and every row of the first resilience table passed while five paths were broken. Found with a 16 KB body and two requests on one socket. Now every step returns the connection it was handed and every refusal answers on that one. @@ -1421,14 +1474,14 @@ shapes raise instead; that is filed, not fixed here. Request bodies are capped at 1 MiB. Without a cap, a body is an unbounded allocation an unauthenticated caller controls. -## [0.2.0] — 2026-09-07 +## [0.2.0] - 2026-09-07 -### Changed — two fields are REMOVED from results for legacy-declared requests +### Changed: two fields are REMOVED from results for legacy-declared requests **Read this before upgrading if any client sends `_meta` naming `2025-11-25`.** A result answering such a request no longer carries `resultType` or `_meta` `io.modelcontextprotocol/serverInfo`. In `0.1.0` it carried both. This is not limited to -`ping` — it applies to every method reaching that path, `tools/list`, `tools/call` and +`ping`; it applies to every method reaching that path, `tools/list`, `tools/call` and `shutdown` included: 0.1.0: tools/list + _meta 2025-11-25 @@ -1440,7 +1493,7 @@ A client that reads `result.resultType` on that path gets `nil`. **That is why t and not a patch.** The argument for a patch was available and is rejected: `0.y.z` sits outside semver's -compatibility contract, and the removed fields were never correct — they announced a revision +compatibility contract, and the removed fields were never correct: they announced a revision the client did not ask for. Neither of those makes the wire change smaller. For a published package the JSON *is* the API. For a client declaring `2025-11-25`: **a method that was refused now answers** (`ping`), and **results on that path have lost two fields**. A `0.1.0` @@ -1452,7 +1505,7 @@ change is additive and is not an argument for it. **This paragraph had the direction backwards and it is worth saying where that came from.** It read "a method that answered now refuses", which is true of no method in this release. That sentence was one of the two grounds given for choosing `0.2.0`, and it travelled from the -decision through to this file without anyone checking it — the second ground, the field removal, +decision through to this file without anyone checking it; the second ground, the field removal, is correct and carries the decision on its own. A reviewer caught it by enumerating every method for a client declaring `2025-11-25` on both this tree and `0.1.0`'s, rather than by reading any of the places it was written down: @@ -1475,7 +1528,7 @@ Requests declaring `2026-07-28`, and requests with no `_meta` at all, are unaffe - **A request declaring `2025-11-25` through per-request `_meta` is now served as `2025-11-25`.** The `_meta` clause branched on the method and never on the declared revision, so `ping` was refused with `-32601` at every revision reaching it, and every - result was decorated with `resultType` and `_meta` `serverInfo` — two fields `2026-07-28` + result was decorated with `resultType` and `_meta` `serverInfo`, two fields `2026-07-28` introduced and `2025-11-25` does not define. Both halves came from one version-blind `cond`. Measured against `0.1.1`, each message the first and only one on a fresh state. (`0.1.1` @@ -1497,15 +1550,15 @@ Requests declaring `2026-07-28`, and requests with no `_meta` at all, are unaffe Why a `_meta` may name the legacy revision at all: this server advertises `2025-11-25` in `server/discover` and lists it in the `-32022` `supported` payload, and the specification - tells a client receiving `-32022` to select from `supported` and **retry the request** — + tells a client receiving `-32022` to select from `supported` and **retry the request**, which produces exactly this message. `_meta` fixes statelessness; the revision it names fixes the semantics. -### Note on `0.1.1` — why the published versions jump `0.1.0` -> `0.2.0` +### Note on `0.1.1`: why the published versions jump `0.1.0` -> `0.2.0` **`0.1.1` does not exist as a release and never will.** It reached `main`, was never published -to Hex, and `mix.exs` now reads `0.2.0`. Its one change — a moduledoc for `BeamMCP.Server`, -which shipped hidden in `0.1.0` — **ships inside this release**, and its entry is kept below +to Hex, and `mix.exs` now reads `0.2.0`. Its one change (a moduledoc for `BeamMCP.Server`, +which shipped hidden in `0.1.0`) **ships inside this release**, and its entry is kept below under its original heading rather than being folded up or deleted. So a reader comparing Hex to this file sees `0.1.0` then `0.2.0`, with a `0.1.1` section in @@ -1516,11 +1569,11 @@ happened, and the work it covered is in `0.2.0`. The `0.1.1` section below is left exactly as written. This note is appended rather than a rewrite, per the corrections-are-appended rule. -## [0.1.1] — unreleased +## [0.1.1] - unreleased > **This version does not exist and never will, and from `0.3.0` this file is published on > hexdocs, so that sentence now needs to be visible here rather than only in the note above.** -> `0.1.1` was numbered on `main`, never released, and its one change shipped inside `0.2.0` — +> `0.1.1` was numbered on `main`, never released, and its one change shipped inside `0.2.0`, > verifiable without leaving the registry: `BeamMCP.Server` renders at > `hexdocs.pm/beam_mcp/0.2.0` and 404s at `0.1.0`. The heading is left as written, per the > corrections-are-appended rule; this note is the correction. @@ -1539,14 +1592,14 @@ output: warning: documentation references module "BeamMCP.Server" but it is hidden warning: documentation references module "BeamMCP.Server" but it is hidden -`BeamMCP.Server` carries `@moduledoc false` — inherited verbatim from the tree it was extracted +`BeamMCP.Server` carries `@moduledoc false`, inherited verbatim from the tree it was extracted from, where it was an internal module and the annotation was correct. It is now the package's principal public module, and `BeamMCP.ToolCatalog`'s docs link to it, so the published docs reference a module hexdocs will not render. The annotation stopped being true the moment the code left the umbrella, and nothing caught it because a hidden module is not a compile warning and the gate does not run `mix docs`. -## [0.1.0] — 2026-09-06 +## [0.1.0] - 2026-09-06 First release. Tag `v0.1.0`, an annotated tag whose object is `69c8159` and whose commit is `2add129e65d04759ce67c77520208b854c05dcee`. @@ -1570,7 +1623,7 @@ publish. A revision check found the reason: > **a `2026-07-28` request answered with a `2024-11-05`-shaped result that looks like success.** A client speaking the current revision sent `tools/list` carrying its version and capabilities -in `_meta`, exactly as that revision requires, and got back a well-formed result — with no +in `_meta`, exactly as that revision requires, and got back a well-formed result, with no `resultType`, no `_meta` `serverInfo`, and none of the fields the revision mandates. Not an error a client could act on. A wrong answer wearing the shape of a right one. @@ -1609,14 +1662,14 @@ fails quietly against the revision most evaluators would try first. ### Fixed - **Error payloads no longer carry `inspect/1` output.** A failed call returned Elixir term - syntax — a map literal and a bare atom — to a client with no way to parse it and no reason + syntax (a map literal and a bare atom) to a client with no way to parse it and no reason to know the server's language. `structuredContent` now carries the error as a JSON object and `content` carries a sentence. ### Removed - Per-tool `input_schema/1` clauses, `input_schema_for/1`, the hardcoded argument-key - allowlist and the domain value coercion — one consumer's domain data in a generic module + allowlist and the domain value coercion: one consumer's domain data in a generic module (`c5da02f`). - **`2024-11-05` is no longer supported.** A client requesting it receives `-32022`. - JSON-RPC batching is refused. It was added in `2025-03-26` and removed in `2025-06-18`, so @@ -1628,7 +1681,7 @@ fails quietly against the revision most evaluators would try first. Three optional items from `2025-11-25`, recorded so their absence is not read as an oversight: - **JSON Schema 2020-12 dialect declaration.** `BeamMCP.Schema` is a deliberately small - subset — `type`, `properties`, `required`, `additionalProperties`, bounds. Declaring a + subset: `type`, `properties`, `required`, `additionalProperties`, bounds. Declaring a dialect it does not fully implement would claim more than it does. - **`title` on tools.** Optional human-readable display name. Nothing in the package needs it, and a catalog that wants one can carry it when the field is supported end to end. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f93426..65ffb47a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 **1. Sign off every commit.** `git commit -s` adds the `Signed-off-by` line, which certifies the [Developer Certificate of Origin](https://developercertificate.org/): you wrote the patch, or have the right to submit it under this project's licence. **CI checks every commit in the -range and fails the whole push if one is missing.** A missing sign-off cannot be waived — the +range and fails the whole push if one is missing.** A missing sign-off cannot be waived; the history has to be rewritten to add it, which is easier before review than after. **2. No tool-attribution trailers.** No `Co-Authored-By` naming a tool, no session links, no @@ -99,7 +99,7 @@ is the project's aim; the pull requests show who reviewed each one. - **A red before a fix.** Show the failure first, in the commit message, with its output. A test that has never been seen failing is not evidence that it works. Where a red is not - available — the code already exists and passes — demonstrate coverage by mutation instead: + available (the code already exists and passes), demonstrate coverage by mutation instead: break the property in a throwaway copy and show the test catches it. - **Counts quoted from command output, never typed.** Test counts, exit codes, file counts. - **SPDX headers** on every `.ex`, `.exs`, `.sh` and `.yml`. The gate checks it, and CI runs diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 4285d969..d231007a 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -10,7 +10,7 @@ How this package is developed. Short, and each entry exists because something we ## The gate takes no baseline `--strict` means what it says. There is no ratchet file, no tolerated count, and no generated -`.credo.exs` — credo runs on its own defaults so that a check cannot be switched off in a +`.credo.exs`; credo runs on its own defaults so that a check cannot be switched off in a config nobody reads. A non-zero count is a failure, not a number to hold. This is deliberate and it is the opposite of the tree this package was extracted from, which @@ -28,13 +28,13 @@ Measured, twice in one day: The check derives its population from `git ls-files`, so the file was invisible. The gate failed for an unrelated reason while the `reuse` line read `pass`. Adding the file first turns it genuinely red. -2. **The sibling tree's link check.** Its source enumeration had no probe that could fail — +2. **The sibling tree's link check.** Its source enumeration had no probe that could fail: three mutations survived, including removing the `match_dot: true` that the file's own comment cites as its reason for existing. The probes guarded target resolution; the enumeration was unguarded. So: **read the step's line, not just the exit code**, and derive the probe's input the way the -mechanism derives its own — same command, same source of truth. +mechanism derives its own: same command, same source of truth. ## CI is unproven until a run exists @@ -98,7 +98,7 @@ Two halves, and the second is what makes the first survive. **The README moves in the slice that changes the behaviour**, not after it. A slice that changes what the package does and leaves the README describing the old behaviour has shipped a false -statement to the artifact a consumer reads first — `mix.exs` puts `README.md` in the Hex package +statement to the artifact a consumer reads first: `mix.exs` puts `README.md` in the Hex package `files:` list and makes it the ex_doc landing page, so after `mix.exs` it is the most-read live file in the tree. @@ -112,13 +112,13 @@ guarding nothing. **Derivation, and it is two failures rather than one.** The first is the ordinary one. A slice fixed a version-blind clause and updated the inline -comment, and left the `@moduledoc` — the module's published documentation — still stating the +comment, and left the `@moduledoc`, the module's published documentation, still stating the rule the change deleted. A reviewer made it a blocking finding. The same slice then replaced a README paragraph that overstated a rule with another paragraph that overstated it, in the fix for the first. -The second is the one that produced this rule. Releasing that work as `0.2.0` — a minor bump -chosen specifically so a consumer *could* pin away from a documented wire break — the README +The second is the one that produced this rule. Releasing that work as `0.2.0` (a minor bump +chosen specifically so a consumer *could* pin away from a documented wire break), the README still recommended `{:beam_mcp, "~> 0.1"}`. Measured with Elixir's own `Version` module rather than recalled: @@ -126,7 +126,7 @@ than recalled: ~> 0.2 0.1.0=false 0.2.0=true <- does not Nothing was broken: a new user copying the snippet installs the right version. The defect is the -other direction — a consumer who copied it at `0.1.0` is carried across the break by a routine +other direction: a consumer who copied it at `0.1.0` is carried across the break by a routine `mix deps.update`, with no change to their own requirement and no signal. **The release shipped the version signal and the advice defeating it in the same commit.** @@ -137,7 +137,7 @@ than grepping for the *string*: a grep finds what you already thought of. ## A report owed only at the end is a report a crash deletes The record is written when each **round** closes, not when the run does. A session that is -terminated mid-work — a rate limit, a crash, a compaction — takes every unwritten conclusion with +terminated mid-work (a rate limit, a crash, a compaction) takes every unwritten conclusion with it, and the work then has to be redone from the tree rather than read from the record. This is not about diligence. It is about where the finding lives: in the run's memory, it is lost @@ -149,11 +149,11 @@ A test that pins a fix must be able to **fail** when the fix is removed. Two sha look identical to a passing suite: - **The contained anchor.** Asserting `old_count == 0` proves nothing when the replacement embeds - the original — the old string is still there, inside the new one. Assert `new_count == 1`, and + the original: the old string is still there, inside the new one. Assert `new_count == 1`, and prove application **by effect**: the mutant must change an observable outcome, not a substring. - **The compiler kill.** A mutant that leaves a function or attribute unused is rejected by `--warnings-as-errors` before the suite runs. The build failed; no test did anything. Complete - the mutation — remove what it orphans — and re-run, or the table records a kill that never + the mutation (remove what it orphans) and re-run, or the table records a kill that never happened. A survivor that is genuinely equivalent is recorded as a survivor, with the argument for why. The @@ -161,7 +161,7 @@ alternative is writing a test that asserts an implementation detail so the table all-killed, which is worse than the survivor: it looks like evidence and is not. **A partial result is reported as partial.** A mutant expected to kill five tests that kills three -is recorded as killing three, with the reason — in the case this rule came from, two of the five +is recorded as killing three, with the reason: in the case this rule came from, two of the five reads did not route their comparison through the mutated helper, because one compares set membership and the other has a second check that fires first. The pull is to describe it as "the class mutant killed everything", which is the false version and the easier sentence. The number in @@ -170,7 +170,7 @@ the table is what the run printed; the explanation goes beside it. ## Every place a mechanism reads the same kind of input is one mechanism When a defect is "this read handles the input wrongly", the fix is not that read. Derive the set -of places that read that input — with a command, recorded, so the derivation is repeatable — and +of places that read that input (with a command, recorded, so the derivation is repeatable) and change all of them, through one path if the comparison allows it. Then say in the record **how the set was derived**, so the next reader can re-derive it rather than trust the list. @@ -188,21 +188,21 @@ checked, which is the thing being defended against, wearing the shape of the fix The instance this rule comes from: `Mcp-Param-{Name}` headers mirror tool arguments, and the transport derived the set to validate by sweeping the `mcp-param-*` headers **the caller sent**. -That looks like a derivation — no hand-written list, a new header inherits the behaviour — and it +That looks like a derivation (no hand-written list, a new header inherits the behaviour), and it made the specification's own requirement unenforceable by construction. The rule is *"client omits the header but the value is in the body → server MUST reject"*, and a server whose population comes from the caller's headers can never see an omission: the caller simply sends nothing and is never checked. Two review rounds and a passing suite did not notice, because every test sent the header it was testing. -The population had to come from the **tool's `inputSchema`** — the side of the exchange the host +The population had to come from the **tool's `inputSchema`**: the side of the exchange the host controls and the client cannot influence. The test to apply: > If a hostile caller can change what gets validated by changing what it sends, the set was not > derived. It was requested. The same question applies to any set built from headers, query parameters, body keys, filenames or -metadata: derive from the schema, the manifest, the catalog, the code — the authority — and use +metadata: derive from the schema, the manifest, the catalog, the code, which are the authority, and use the request only as the thing measured against it. ## Slice directories own the numbers; features have names and a rank @@ -213,48 +213,48 @@ order, never a number.** The rule exists because the two namespaces were shared for two days and collided twice in that time. `003` named both `slices/003-release-0-3-1/` and an unstarted feature issue titled -*"003 — provenance-bound tool identity"*. Hours later `004` named both `slices/004-gate-honesty/`, -which was on a pushed branch with work in it, and a newly filed *"004 — auth as a resource +*"003: provenance-bound tool identity"*. Hours later `004` named both `slices/004-gate-honesty/`, +which was on a pushed branch with work in it, and a newly filed *"004: auth as a resource server"*. -Neither collision was a mistake in the moment. Both were the same reasonable act — reaching for -the next free number — performed against two lists that each thought they owned the sequence. That +Neither collision was a mistake in the moment. Both were the same reasonable act (reaching for +the next free number), performed against two lists that each thought they owned the sequence. That is what makes it a convention rather than an incident: **the second collision was committed by someone who had just been told about the first**, and it will keep happening as long as two things can claim the same number. The asymmetry is what settles which side keeps the numbers. A slice number is **load-bearing**: it is a directory path, a branch name, and a string inside commit messages and archived logs that -cannot be revised without falsifying the record. A feature's number is **decorative** — the +cannot be revised without falsifying the record. A feature's number is **decorative**: the ordering is the real content, and a rank expresses it without consuming a name. So the numbers stay where they cost something to move, and the ordering lives in the record that can be edited. A consequence worth stating, because it looks like an omission: **the ranked order lives on the board, not in a filename.** Anyone wanting to know what comes next reads the ranking, and a -reordering is a comment rather than a rename. That is the point — a rename would have to reach into +reordering is a comment rather than a rename. That is the point: a rename would have to reach into the one namespace this rule protects. ## A slice's review tier is declared before the work, by what it changes for a stranger Three tiers, chosen in the PLAN before the first commit, by what the slice changes for someone who -consumes the package — not by which files it touches. `mix.exs` can carry a consumer-visible hard +consumes the package, not by which files it touches. `mix.exs` can carry a consumer-visible hard stop; a README sentence can carry nothing. -1. **Contract** — new or changed behaviour a consumer can hit: a raise, the wire, the catalog, reach, +1. **Contract**: new or changed behaviour a consumer can hit: a raise, the wire, the catalog, reach, a sign slot, an authority boundary, a host contract. **Two lanes.** Mutants if there is a pin to kill. -2. **Measurement** — CI, gates, pins that make an existing claim true. **One lane, one round.** -3. **Prose and records** — this file, FINDINGS, gap notes, README sentences that change no behaviour. +2. **Measurement**: CI, gates, pins that make an existing claim true. **One lane, one round.** +3. **Prose and records**: this file, FINDINGS, gap notes, README sentences that change no behaviour. **The gate and a self-review. No lanes.** Three limits sit beside the tiers. **A copy edit never opens a review round:** a claim about -behaviour gets a round; a sentence about the same behaviour gets a commit. **Instrument gaps** — -archive drift, `MIX_ENV` inheritance, a count field the harness stopped reading, signoff tooling — +behaviour gets a round; a sentence about the same behaviour gets a commit. **Instrument gaps** +(archive drift, `MIX_ENV` inheritance, a count field the harness stopped reading, signoff tooling) batch into one tools slice or wait until they block a slice; they do not ride along. **The tier cannot be chosen after the result is known.** -Two measurements produced this. Slice 022 set the OTP floor in `mix.exs` — a contract, `project/0` -raises and a dependent has no bypass — and then spent a third review round on "where" against +Two measurements produced this. Slice 022 set the OTP floor in `mix.exs` (a contract, `project/0` +raises and a dependent has no bypass) and then spent a third review round on "where" against "whereas", and on a `.tool-versions` pointer that dangles for a hex consumer: three rounds of the maximum ceremony, one of them for wording. Uniform maximum ceremony was the tax. Slice 023, the CI matrix, was the test: a measurement, so it ran one lane, and that is the shape this rule exists to diff --git a/FINDINGS.md b/FINDINGS.md index 3d3e8c32..4eaf0e56 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -3,12 +3,12 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# beam_mcp — FINDINGS +# beam_mcp: FINDINGS Source: Ultraviolet `origin/main` = `778accb82759d3def30b2e1410e1aeb573f76dac`. Nothing is committed in this repository yet. `main` had no commits at start. -## The seam is nominal — demonstrated red, in Ultraviolet, before any extraction +## The seam is nominal: demonstrated red, in Ultraviolet, before any extraction The owner named `normalize_tool_name/1` as the demonstration. It is one, and here it is failing, run in a throwaway worktree at `778accb8` with `_build`/`deps` copied (§4b), the @@ -41,7 +41,7 @@ real catalog, so the two lists happen to agree. `normalize_tool_name/1` calls `HacktuiAgent.MCP.ToolCatalog.all()` **directly**, and `ToolCatalog` is on the owner's stay-behind list. So the package cannot compile without -changing that line — and changing it *is* commit 2's fix. **Commits 1 and 2 collapse.** +changing that line, and changing it *is* commit 2's fix. **Commits 1 and 2 collapse.** The only alternative is to hardcode the six Ultraviolet tool names in the package so the defect survives verbatim, which the scope forbids outright: domain data in a generic module @@ -50,7 +50,7 @@ is the thing this extraction exists to remove. ## Two other changes commit 1 cannot avoid, declared 1. **The `new/1` defaults.** `dispatch:` defaults to `&Dispatch.safe_call/3` and - `tool_catalog:` to `ToolCatalog` — both stay behind. They must become injected. No test + `tool_catalog:` to `ToolCatalog`; both stay behind. They must become injected. No test changes: all four server tests pass `tool_catalog:`, and the three that omit `dispatch:` never reach a dispatch. 2. **`@server_name "hacktui-hermes"`** (`server.ex:11`) is Ultraviolet branding compiled into @@ -62,10 +62,10 @@ is the thing this extraction exists to remove. `mcp_stdio_framing_test.exs` cannot travel: it drives the real `bin/hacktui-mcp` binary, computes `repo_root()` as the umbrella root, runs `mix compile` there in `setup_all`, and is -tagged `:mcp_e2e`. Making it run here needs a package-local launcher — new work, not a move. +tagged `:mcp_e2e`. Making it run here needs a package-local launcher: new work, not a move. So the transport ships with **no in-package test**. Recorded as a gap, not hidden. -## NOTICE — the difference the owner asked me to report rather than reconcile +## NOTICE: the difference the owner asked me to report rather than reconcile Ultraviolet's `NOTICE` at `778accb8` reads, in full on the copyright line: @@ -73,6 +73,6 @@ Ultraviolet's `NOTICE` at `778accb8` reads, in full on the copyright line: Copyright 2026 Ayla Croft It names **one** role. It does not mention Sudo Apt Holdings LLC, Script Kitty, the ORCID, or -any separation of ownership from authorship — consistent with slice 14 being unstarted. +any separation of ownership from authorship, consistent with slice 14 being unstarted. `beam_mcp`'s `NOTICE` is written from the owner's text and carries all three roles. **The two files disagree, deliberately, and reconciling Ultraviolet's is slice 14's work, not mine.** diff --git a/HANDOFF.md b/HANDOFF.md index ef9f6a10..18e2295b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,18 +3,34 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# HANDOFF: beam_mcp, release 0.9.0 prepared; publish and tag are the owner's +# HANDOFF: beam_mcp, release 0.10.0 prepared; publish and tag are the owner's -Tag and publish are owner steps — never `mix hex.publish`, never push a tag, never bump the -version in `mix.exs`. For 0.9.0 the version bump is this release commit, reviewed like any +Tag and publish are owner steps: never `mix hex.publish`, never push a tag, never bump the +version in `mix.exs`. For 0.10.0 the version bump is this release commit, reviewed like any other change; publishing and tagging remain the owner's, in the order the runbook below gives. -The slice records — plans, findings, lane reports, signoffs, archived gate runs — live in the +The slice records (plans, findings, lane reports, signoffs, archived gate runs) live in the project's internal tree, not in this repository. Nothing here summarises a review that has not happened. ## State +- **`0.10.0` is the quiet minor again**: no public entry added, removed, renamed, hidden or + changed in arity (`docs/public-api.txt` did not move; `release_markers!("0.10.0")` wrote + nothing), no wire or envelope byte (the recording's ten version lines re-taken and nothing + else; every canonical golden the same blob as at `0.9.0`). Instruments: the suite in FIPS + mode in CI (`.github/workflows/fips.yml`: OTP 28.1.1 `--enable-fips` over OpenSSL 3.5.8 with + the 3.1.2 FIPS provider, CMVP #4985; 11 properties, 729 tests, 0 failures in FIPS mode, + measured locally on that toolchain and in CI), and an attested CycloneDX SBOM at every + release (`tools/sbom.sh`, the EEF's `mix_sbom` pinned by digest, outside `mix.exs`; bound to + the tarball's digest by a second attestation that the workflow downloads back and compares). + Pages: the OpenSSF Best Practices badge's (architecture, assurance case, roadmap, code of + conduct, code review, security-review procedure, continuity), `docs/fips.md` measured and + corrected (a missing provider fails at the first `:crypto` call, not at boot), the + supply-chain row's status, README's "Scheduled" after `1.0.0`, and no em dash left outside + `slices/`. `mix.exs` says `0.10.0`; the README recommends `~> 0.10.0` and the requirement + test refuses `0.9.0`, `0.8.0`, `0.7.0` and `0.6.0`. `1.0.0` is next, after this minor has + stood. - **`0.9.0` carries two additions and no break**, and ends the stand at `0.8.0`: the `:server` seam (a module option on `BeamMCP.Transport.HTTP`'s Plug and `BeamMCP.Transport.Stdio.run/1`, default `BeamMCP.Server`, the transports reaching `new/1`, `handle_message/2` and (stdio) @@ -32,8 +48,8 @@ happened. `0.6.0`. `0.10.0` is next, the quiet minor (a FIPS leg, the SBOM at release: instruments and pages, no public entry), then `1.0.0` after it has stood. - **`0.8.0` was the quiet minor**: no public entry added, removed, renamed, hidden or changed - in arity — `docs/public-api.txt` is `0.7.0`'s line for line, `release_markers!("0.8.0")` - wrote nothing — and no wire or envelope byte moved. Instruments: the gate's sixteenth step + in arity (`docs/public-api.txt` is `0.7.0`'s line for line, `release_markers!("0.8.0")` + wrote nothing), and no wire or envelope byte moved. Instruments: the gate's sixteenth step diffs the baseline against `origin/main` (G-076); the pull-request summary waits for running legs on a body edit (G-079); the honesty probe's discriminator reads detailed pass lines (G-078); each with an offline probe. Pages: the governance table carries the Scorecard's @@ -45,11 +61,11 @@ happened. `lib/`) and `BeamMCP.Connectome.Canonical.signature/3` (the one call site, over `encode/2`'s bytes, moving no envelope byte). Three public entries added, none removed, renamed or hidden; `docs/public-api.txt` marks them `since=0.7.0` (the release step wrote three). The - no-signature census pins the seam — the callback's whole spec, each behaviour's exact - callback list, the one `def sign`, the one call site — and sixteen mutants hold it. The + no-signature census pins the seam (the callback's whole spec, each behaviour's exact + callback list, the one `def sign`, the one call site), and sixteen mutants hold it. The signer that holds a key, `BeamMCP.Signer.Ed25519` (Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`), is the separate package `beam_mcp_signer` - (github.com/ScriptKittyOS/beam_mcp_signer, 0.1.0 on hex.pm, depending on `~> 0.7.0` — so a + (github.com/ScriptKittyOS/beam_mcp_signer, 0.1.0 on hex.pm, depending on `~> 0.7.0`, so a host on it cannot take `0.8.0` until a signer release admits it; `UPGRADING.md` says so; and `0.1.1`, released 2026-09-19 with `~> 0.7`, is that release: it resolves beside `0.9.0`); this package does not depend on it. @@ -61,24 +77,25 @@ happened. governance and succession, the export-control statement and REUSE compliance by the specification's tool. **One break at the minor**, in the exported bytes (the canonical envelope's algorithm member and `schema_version` 3), with its how-to-tell sentence. **The - road from here is written in `UPGRADING.md`** — `0.7.0` the signer seam, `0.8.0` a quiet - minor, `1.0.0` after it — so nobody reads it off a plan's label. + road from here is written in `UPGRADING.md`** (`0.7.0` the signer seam, `0.8.0` a quiet + minor, `1.0.0` after it), so nobody reads it off a plan's label. - At `0.6.0` the README recommended `~> 0.6.0` (a fifth use of the minor position) and `docs/public-api.txt` carried no `Unreleased` marker, so `release_markers!("0.6.0")` wrote nothing; at `0.7.0` it wrote three. -- **No head hash is written here** — a hash written into the file it describes cannot include - the commit that writes it. `git log v0.8.0..main` is the authority. +- **No head hash is written here**: a hash written into the file it describes cannot include + the commit that writes it. `git log v0.9.0..main` is the authority. - Gate on the release commit: sixteen steps, every line `pass` (the 0.5.0 gate had thirteen; the audit step made it fourteen in 024, Dialyzer fifteen in 027a, the baseline diff sixteen - at 0.8.0) — format (the tracked set, not a + at 0.8.0): format (the tracked set, not a glob), compile, dialyzer, instruments, test, credo, properties (11 at 1 000 generations), optional deps, audit, bench (the collector's overhead under the 1.5 µs ceiling; **the diff engine's run and - encode each under its own ceiling now** — 245 ms and 260 ms, medians of five in a fresh + encode each under its own ceiling now**: 245 ms and 260 ms, medians of five in a fresh process after a warm-up, set at roughly double the stable worst of ten runs on the release head; the reachability queries' cost recorded and judged by no number, but a query refused on the fixture fails the step by name), docs, reuse, licence files, publication, baseline, messages. - **11 properties, 729 tests, 0 failures** on the release tree (705 at 0.8.0 and 0.7.0, since 0.8.0 + **11 properties, 729 tests, 0 failures** on the release tree (729 at 0.9.0 as well: 0.10.0's + instruments are workflows, measured by their CI runs, and add no test; 705 at 0.8.0 and 0.7.0, since 0.8.0 added no test and its instruments are probed by shell; 692 at 0.6.0, 604 at 0.5.0; the differences are the slices' own pins: at 0.9.0 the server seam's census and by-effect tests and the scheme's tests; at 0.7.0 the signer seam's census and behaviour tests; before it the floor, the provenance and security-policy pins, the tracer's session @@ -91,20 +108,16 @@ happened. ## What is next -The road, as `UPGRADING.md` states it and the owner locked it: **`0.7.0`** the signer seam -(`BeamMCP.Signer`, a behaviour added to the public surface and no authority; called the last -intentional addition, and superseded there by an appended sentence), **`0.8.0`** the quiet -minor in which no public entry moved, **`0.9.0`** this release (the `:server` seam and the -scheme beside the signature, two additions a consumer's composed system needed before `1.0.0` -could be an honest freeze), **`0.10.0`** the quiet minor again (instruments and pages: a FIPS -leg, the SBOM at release; no public entry), **`1.0.0`** after it has stood, the README's -condition, that the public API and the stated threat model have each survived a full minor -release unchanged. -The federation seam stays held on another board's answer and is not on that road. A compiler-tracer census -(module-body code run at compile time, which neither the text censuses nor `:xref` see) is -scheduled with its lift measured. Sign-aware reachability, if asked for, is a later slice or a -refusal decided in the open — never a widening inside a release slice; `all_paths` stays -refused. +**`1.0.0`**, once this minor has stood: the README's condition, that the public API and the +stated threat model have each survived a full minor release unchanged. `0.10.0` moved no public +entry, and its one threat-model edit corrects a status (provenance and the SBOM shipped), not +what the model defends. The freeze is `docs/public-api.txt` as it stands; `docs/api-stability.md`'s +1.x rules take effect. After it, as additions at the minor: the federation seam (held on +another board's answer), effective connectivity, the Tasks extension. A compiler-tracer census +(module-body code run at compile time) is scheduled with its lift measured. Sign-aware +reachability, if asked for, is a later slice or a refusal decided in the open; `all_paths` +stays refused. Outside the package: `mix_sbom`'s reading of `tools: :optional` (the named +workaround in `tools/sbom.sh` goes when an upstream release reads it). ## Owner decisions still open @@ -113,7 +126,7 @@ refused. is advertised with `subscribe: false`. Whether to build `subscriptions/listen` on stdio, the legacy pair on the legacy era only, or neither and say so on the will-not-implement page. 2. **A resource template in the declared connectome.** The builder reads a `uri` per entry; a - template has a `uri_template` and is enumerated as unreadable — true and unflattering. + template has a `uri_template` and is enumerated as unreadable: true and unflattering. Whether a template is a node, and of what kind. 3. **`tools/list` pagination.** The cursor exists and both resource lists and `prompts/list` use it; adopting it on `tools/list` changes an existing result and waits for the word. @@ -144,7 +157,7 @@ refused. - The `2025-11-25` revision is served on stdio only; over HTTP the conformance row for it is 0 / 30 by design, and the README says so beside the number. -## The release steps, the runbook (followed for 0.6.0, 0.7.0 and 0.8.0; the same for 0.9.0) +## The release steps, the runbook (followed for 0.6.0 to 0.9.0; the same for 0.10.0) 1. The release PR merged to `main` by rebase (the ruleset requires two green checks); `main` is then the release commit. @@ -153,10 +166,10 @@ refused. 3. Tag the release commit **as it sits on `main` after the rebase-merge** (a new SHA; the bytes are a function of the tree, measured) locally, signed (`git tag -s vX.Y.Z`); then `tools/release_tarball.sh vX.Y.Z beam_mcp-X.Y.Z.tar --publish` (the script builds the - canonical tarball from `git archive` of that tag and publishes from that tree — a + canonical tarball from `git archive` of that tag and publishes from that tree; a working-tree `mix hex.publish` ships that machine's file modes and is not what the provenance workflow attests); **then** push the tag. The tag's run downloads what hex.pm serves and verifies the attestation against it, and treats a version hex.pm does not serve - yet as a failure — so the push comes last. (A tag and its commit build the same bytes; + yet as a failure, so the push comes last. (A tag and its commit build the same bytes; measured.) The GitHub ruleset targets **branches, not tags**, so a tag push is unprotected: what is tagged is what was read. diff --git a/PLAN.md b/PLAN.md index 050ba02e..72c5573f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# beam_mcp — PLAN +# beam_mcp: PLAN **Repository:** `github.com/ScriptKittyOS/beam_mcp` (transferred from `HackTuah` 2026-09-06; the old path still resolves by redirect). Private, empty at start -- `main` had no commits. @@ -35,9 +35,9 @@ validation only), `tool_spec.ex`. | `HacktuiAgent.MCP.Stdio` | `BeamMCP.Transport.Stdio` | | `HacktuiAgent.MCP.Schema` | `BeamMCP.Schema` | | `HacktuiAgent.MCP.ToolSpec` | `BeamMCP.ToolSpec` | -| — (new) | `BeamMCP.ToolCatalog` (behaviour) | +| none (new) | `BeamMCP.ToolCatalog` (behaviour) | -Only external dependency: `jason`. Measured — `server.ex` and `stdio.ex` reference `Jason`; +Only external dependency: `jason`. Measured: `server.ex` and `stdio.ex` reference `Jason`; `schema.ex` and `tool_spec.ex` reference nothing outside themselves. ## The two contracts the package must define @@ -48,7 +48,7 @@ Injection without a specification is a claim with nothing behind it. So: `@type dispatch :: (atom(), map(), keyword() -> {:ok, term()} | {:error, term()})`. `safe_call/3` is specced `(atom(), term(), keyword()) :: {:ok, term()} | {:error, term()}`, so it satisfies the callback; the package's type is the narrower, published one. -2. **A `BeamMCP.ToolCatalog` behaviour** — `@callback all() :: [BeamMCP.ToolSpec.t()]` — which +2. **A `BeamMCP.ToolCatalog` behaviour**, `@callback all() :: [BeamMCP.ToolSpec.t()]`, which Ultraviolet's concrete catalog implements. ## The demonstration that the seam is currently nominal @@ -64,14 +64,14 @@ real catalog as well. That coincidence is what commit 2 removes. ## Three commits, each proven before the next -### Commit 1 — the move, behaviour-preserving +### Commit 1: the move, behaviour-preserving Four files, module renames only. Both test runs pasted with pass counts and exit codes. **If a test needs a substantive edit to pass, stop and report; do not edit it.** **One unavoidable source change, declared rather than smuggled:** the package cannot default `:dispatch` to `HacktuiAgent.MCP.Dispatch.safe_call/3` or `:tool_catalog` to -`HacktuiAgent.MCP.ToolCatalog` — those are exactly the modules that stay behind. The defaults +`HacktuiAgent.MCP.ToolCatalog`; those are exactly the modules that stay behind. The defaults are therefore dropped and both become required injection. This is a **behaviour change for a caller that omits them** and is not a rename. It changes no test: all four server tests inject `tool_catalog:`, and the three that omit `dispatch:` never reach a dispatch. Ultraviolet's @@ -96,13 +96,13 @@ in this package**. Its only test is bound to Ultraviolet's launcher. Closing tha transport test written against `BeamMCP.Transport.Stdio` directly, which is new work and is not commit 1. -### Commit 2 — catalog injection fixed, red first +### Commit 2: catalog injection fixed, red first Inject a catalog holding **one tool the real catalog lacks**; assert `tools/call` reaches dispatch. It fails today, because `normalize_tool_name/1` consults the real catalog. Red recorded verbatim before the fix. -### Commit 3 — schema onto ToolSpec, red first +### Commit 3: schema onto ToolSpec, red first Construct a `ToolSpec` carrying a schema the server has never seen; assert validation uses it. The per-tool `input_schema/1` clauses (`server.ex:223-299`) and the argument normalisation @@ -123,7 +123,7 @@ Apache-2.0. SPDX headers on every source file, REUSE-compliant (`LICENSES/Apache `NOTICE` carries three roles: **Sudo Apt Holdings LLC** owns the IP, **Script Kitty** built it, **Ayla Croft** authored it (ORCID `0009-0008-9457-2160`). Written from the owner's text. -**Ultraviolet's NOTICE is deliberately not copied** — see the difference recorded in +**Ultraviolet's NOTICE is deliberately not copied**; see the difference recorded in `FINDINGS.md`. ## Gates for this tree @@ -133,5 +133,5 @@ if configured. No ratchet baselines: this tree starts clean and stays at zero. ## Out of scope -Publishing. Revision negotiation and `server/discover` — the next slice, in this package. +Publishing. Revision negotiation and `server/discover`: the next slice, in this package. The Ultraviolet path-dep change is a separate PR in that tree with its own review. diff --git a/README.md b/README.md index 3266d286..4df2d4dd 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ SPDX-License-Identifier: Apache-2.0 [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14774/badge)](https://www.bestpractices.dev/projects/14774) -A Model Context Protocol server core for the BEAM. Protocol handling, two transports — stdio and -a stateless Streamable HTTP `Plug` — and JSON Schema validation, with the tool catalog and the +A Model Context Protocol server core for the BEAM. Protocol handling, two transports (stdio and +a stateless Streamable HTTP `Plug`) and JSON Schema validation, with the tool catalog and the dispatch function injected by the host. The package holds no tools, no domain, and no policy. It decides what a well-formed request is @@ -16,15 +16,15 @@ and refuses one that is not; what a tool *does* is the host's business. ```elixir def deps do - [{:beam_mcp, "~> 0.9.0"}] + [{:beam_mcp, "~> 0.10.0"}] end ``` -**`~> 0.9.0`, not the more usual `~> 0.9`.** While this package is `0.x` it documents breaks +**`~> 0.10.0`, not the more usual `~> 0.10`.** While this package is `0.x` it documents breaks at the **minor** position, and it has used that position five times: `0.2.0` removed two fields from results for legacy-declared requests, `0.3.0` added the HTTP transport and the `ttlMs`/`cacheScope` fields `2026-07-28` requires on `tools/list`, `0.4.0` replaced the -catalog behaviour a host implements — `BeamMCP.ToolCatalog` by `BeamMCP.Catalog` — a break in +catalog behaviour a host implements (`BeamMCP.ToolCatalog` by `BeamMCP.Catalog`), a break in the host contract rather than on the wire, and `0.5.0` reads a request's `_meta` at `params._meta` and refuses it at the top level (on the wire), renames the one sign the package writes and moves `schema_version` to `2` (in the exported bytes), and requires a catalog's @@ -32,18 +32,18 @@ writes and moves `schema_version` to `2` (in the exported bytes), and requires a names the canonical envelope's algorithm in its bytes and moves `schema_version` to `3` (in the exported bytes), each with a how-to-tell sentence in the changelog (`0.7.0` is an addition at the minor, the signer seam; `0.8.0` a quiet minor in which no public entry moved; `0.9.0` two -additions at the minor, the `:server` seam and the scheme beside the signature; none a break). -`~> 0.9` would admit a `0.10.0`, so it -would carry you across the next such break on a routine `mix deps.update`; `~> 0.9.0` does not. The tighter form is deliberate and is not an over-pin to be tidied away. What the pin +additions at the minor, the `:server` seam and the scheme beside the signature; `0.10.0` a +quiet minor again; none a break). `~> 0.10` would admit a `0.11.0`, so it +would carry you across the next such break on a routine `mix deps.update`; `~> 0.10.0` does not. The tighter form is deliberate and is not an over-pin to be tidied away. What the pin buys is written down: [`docs/api-stability.md`](docs/api-stability.md) says what is public (what ex_doc lists, `docs/public-api.txt` line by line), how a deprecation runs (three steps, three minors), and what a `0.x` break must say; a census holds the surface to that record. [`UPGRADING.md`](UPGRADING.md) lists every break so far, one line each, and what `1.0` will ask. **From `0.6.0`, the tarball is attested, and the attestation binds to the checksum hex.pm -shows.** On a release tag, CI builds the tarball with `tools/release_tarball.sh` — the +shows.** On a release tag, CI builds the tarball with `tools/release_tarball.sh`, the one way that gives the same bytes on every machine (`mix hex.build` on a working tree carries -that machine's file modes and directory order) — attests its SHA-256 with GitHub's +that machine's file modes and directory order), attests its SHA-256 with GitHub's build-provenance attestation, and verifies the attestation against the bytes hex.pm serves. A release verifies when it was published with the same script; `gh attestation verify beam_mcp-.tar --repo ScriptKittyOS/beam_mcp` checks it. `0.5.0` and earlier carry no @@ -53,16 +53,16 @@ attestation ([`docs/provenance.md`](docs/provenance.md)). Injection without a specification is a claim with nothing behind it, so both are declared. -**`BeamMCP.Catalog`** — the host names what it offers. +**`BeamMCP.Catalog`**: the host names what it offers. `capabilities/0` returns a map with three required keys. `tools` holds `BeamMCP.ToolSpec` structs; `resources` holds `BeamMCP.ResourceSpec` and `BeamMCP.ResourceTemplateSpec` structs -— one list, two kinds, no `uri` or `uri_template` twice — and a catalog that lists either +(one list, two kinds, no `uri` or `uri_template` twice), and a catalog that lists either also exports `read_resource/1`; `prompts` holds `BeamMCP.PromptSpec` structs, each with its `BeamMCP.PromptArgument` list, and a catalog that lists a prompt exports `get_prompt/2`. **An absent key is a malformed catalog, not an empty one**, and `BeamMCP.Server.new/1` -refuses it at startup rather than at the first request — as it refuses a `resources` or +refuses it at startup rather than at the first request, as it refuses a `resources` or `prompts` entry that is not its struct, a repeated key, and a listed resource, template or prompt with no reader. @@ -114,11 +114,11 @@ end **Resources are advertised and read from one reader.** `resources/list` and `resources/templates/list` serve what `capabilities/0` names, sorted by `uri` and `uriTemplate`; `resources/read` accepts a uri only when that same list names it or a listed -template matches it (RFC 6570 `{var}` for one non-empty segment, `{+var}` across segments — +template matches it (RFC 6570 `{var}` for one non-empty segment, `{+var}` across segments; nothing more is claimed, and a template with any other expression, or a bare brace, is -refused at startup) and refuses any other as not found before the reader runs — `-32602` +refused at startup) and refuses any other as not found before the reader runs (`-32602` with the uri as data under `2026-07-28`, `-32002` under `2025-11-25`, the code each revision -names for it — so what is advertised and what is readable cannot drift. The read is the +names for it), so what is advertised and what is readable cannot drift. The read is the catalog's `read_resource/1`: `{:ok, contents}` with `text` as a string or `blob` as raw bytes (base64 on the wire), or `{:error, reason}`, carried to the client as the same not-found code with the reason as data. Both lists are @@ -132,21 +132,21 @@ and `subscribe: false`. **Prompts take the tools' own validation path.** `prompts/list` serves what `capabilities/0` names, sorted by name and paginated by the same cursor; `prompts/get` renders only a prompt -the list names — an unknown name is `-32602` with the name as data, before the reader runs — +the list names (an unknown name is `-32602` with the name as data, before the reader runs), and its arguments are validated by the tools validator over a JSON Schema derived from the declared argument list (`BeamMCP.PromptSpec.argument_schema/1`: one `string` property per argument, `required` from the flags, nothing undeclared admitted), then handed to `get_prompt/2` keyed by the declared names, as a tool's arguments reach its dispatch. One validator, one normaliser, two callers; a caller's argument name becomes an atom on neither path, measured over 10,000 distinct keys. The reader answers `{:ok, %{messages: [%{role: -:user | :assistant, text: ...}], description: ...}}` — text content only, as for tools — or +:user | :assistant, text: ...}], description: ...}}` (text content only, as for tools) or `{:error, reason}`, carried as `-32602` with the reason as data. `prompts/list` carries `prompts_ttl_ms:` / `prompts_cache_scope:` (defaults `0` / `"private"`); `prompts/get` is not cacheable and carries neither. `prompts` is advertised with `listChanged: false`; `completion/complete` belongs to the separate `completions` capability, which this package does not advertise. -**The dispatch callback** — the host does the work. +**The dispatch callback**: the host does the work. ```elixir @type dispatch :: (atom(), map(), keyword() -> {:ok, term()} | {:error, term()}) @@ -167,19 +167,19 @@ to `beam_mcp`, and a host that wants its own name in `initialize` says so. ### The OTP floor -This package requires **Erlang/OTP 27 or newer** and **Elixir 1.17 or newer** — 1.17 is the -oldest Elixir that supports OTP 27, so the two minimums are one coherent pair — the OTP half +This package requires **Erlang/OTP 27 or newer** and **Elixir 1.17 or newer** (1.17 is the +oldest Elixir that supports OTP 27, so the two minimums are one coherent pair), the OTP half enforced at compile time: `mix.exs` reads `:erlang.system_info(:otp_release)` at `project/0` and a below-floor build fails with a message that names the floor and why, rather than compiling and failing later in a way that looks like a defect here. The reason, so the floor is not raised by the next person who -finds it inconvenient: OTP **27.0** added the `trace` module — isolated trace sessions, -`:trace.session_create/3` — and the connectome tracer runs inside one of its own, so that a +finds it inconvenient: OTP **27.0** added the `trace` module (isolated trace sessions, +`:trace.session_create/3`), and the connectome tracer runs inside one of its own, so that a process a host already traces is traced too and a host's own patterns and flags are never touched (`docs/connectome-observed.md`); so 27 is the hard requirement. It is also the oldest release this project *supports*: the lowest leg the CI matrix runs the suite on, so that support is a measurement and not a hope. The suite runs on OTP 27, 28 and 29 in CI -(the floor, the pinned line and the newest pair the compatibility table lists — `mix format` is +(the floor, the pinned line and the newest pair the compatibility table lists; `mix format` is measured on the pinned line only, the formatter being one program) and on 28 on the maintainers' machines; releases older than 27 are neither tested nor supported. @@ -188,7 +188,7 @@ maintainers' machines; releases older than 27 are neither tested nor supported. A tool's schema lives on its `BeamMCP.ToolSpec`. `tools/list` advertises **that** schema and `tools/call` enforces **that** schema, so the contract a client is shown and the contract it is held to cannot drift apart. Argument keys are derived from the schema's `properties` and reach -`dispatch` as **atoms** — a tool declaring `"place"` is dispatched `%{place: "Oslo"}`, not +`dispatch` as **atoms**: a tool declaring `"place"` is dispatched `%{place: "Oslo"}`, not `%{"place" => "Oslo"}`. Values are passed through unchanged, because turning a string into a domain term is the host's job and a generic layer that guesses has acquired someone else's domain. @@ -197,15 +197,15 @@ A `BeamMCP.ToolSpec` that omits `input_schema` is a tool with no arguments: it a empty object, so `tools/call` refuses nothing and dispatch is handed `%{}` whatever the client sent. -Validation is a deliberately small subset of JSON Schema — `type`, `properties`, `required`, +Validation is a deliberately small subset of JSON Schema: `type`, `properties`, `required`, `additionalProperties`, and bounds. It refuses rather than guesses, and it is not a general validator. ## Transports -**stdio** — `BeamMCP.Transport.Stdio.run/1`, newline-delimited JSON-RPC over a pipe. +**stdio**: `BeamMCP.Transport.Stdio.run/1`, newline-delimited JSON-RPC over a pipe. -**HTTP** — `BeamMCP.Transport.HTTP`, a `Plug` serving the `2026-07-28` stateless model at one +**HTTP**: `BeamMCP.Transport.HTTP`, a `Plug` serving the `2026-07-28` stateless model at one endpoint: no sessions, no `Mcp-Session-Id`, no SSE resumability. `plug` and `bandit` are optional dependencies; a stdio-only host does not pull them in. @@ -260,7 +260,7 @@ end ``` **`authorize` and `allowed_origins` are required and have no defaults.** Omit either and the Plug -raises when it is initialised — at start, not on the first request. +raises when it is initialised: at start, not on the first request. That is deliberate. This package cannot decide who may call your tools: it has no view of your identity model, and deciding for you would be claiming something it cannot keep. But serving @@ -272,14 +272,14 @@ say so: `authorize: fn _conn -> :ok end`. **`authorize/1` must not read the request body.** It runs before this Plug reads it, and `Plug.Conn.read_body/2` can be called once: a host that consumes the body in `authorize/1` leaves the transport nothing to parse, and the request fails as a parse error rather than as -whatever the host meant. Authorize on the `Plug.Conn` — headers, peer, assigns set by an earlier -plug — and if a decision genuinely needs the payload, make it in `dispatch/3`, which is handed +whatever the host meant. Authorize on the `Plug.Conn` (headers, peer, assigns set by an earlier +plug), and if a decision genuinely needs the payload, make it in `dispatch/3`, which is handed the decoded arguments. Said plainly, because it is a real limitation and not a preference: **body-signature authentication is not possible in `authorize/1`.** The callback runs before the body is read and returns `:ok | {:error, reason}`, with no way to hand back the `conn` it read from. A host that -reads the body there does not get an error — a small request appears to work because the body is +reads the body there does not get an error: a small request appears to work because the body is already in the adapter's buffer, and a larger one hangs until `read_timeout:` lapses and then returns `408` with the connection dead. Measured: 119 bytes `200`, 16 KiB and 200 KiB both `408` after 15.0 s. Today the workarounds are a plug in front of this one that reads the body and re-supplies it, @@ -300,7 +300,7 @@ argument is the request body exactly as received.** Not a re-encoding of it: a s bytes, so a hook handed `Jason.encode!(Jason.decode!(body))` would reject every correct signature while looking like a fault in the host's cryptography. -It is optional — absent, it is skipped and nothing changes. Present, it must be a 2-arity +It is optional: absent, it is skipped and nothing changes. Present, it must be a 2-arity function or the Plug raises at `init/1`, so a wrong arity is a startup failure rather than a per-request one. @@ -328,19 +328,19 @@ the connection is clean, and an ordinary response is possible. itself over HTTP/1: a declared 32 MiB body is refused after `read_body/2` returns a partial of exactly **1,048,576 bytes**, constant across six socket-buffer settings and four runs (over HTTP/2 the adapter hands whole frames, so the read is the cap plus the frame that crosses it, at most - 16 KiB — the threat model's row). How much the + 16 KiB; the threat model's row). How much the **client** got onto the wire by then is a different quantity and not a property of this - package — the same 24 measurements put it between 1.125 MiB and 7.438 MiB, varying run to run - at one fixed buffer size — so there is no number to design against there, only the + package: the same 24 measurements put it between 1.125 MiB and 7.438 MiB, varying run to run + at one fixed buffer size, so there is no number to design against there, only the server-side constant above. - It is a time bound, and the bound is yours: `read_timeout:` (default 15,000 ms, a chosen - number with its reasoning beside the constant) is one whole-body deadline, this package's own - — the body is read in pieces against one clock, each read given what remains, so a drip client + number with its reasoning beside the constant) is one whole-body deadline, this package's own: + the body is read in pieces against one clock, each read given what remains, so a drip client is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads (the adapter's own `:read_timeout` is a per-read clock: a cap-sized body is two adapter reads and got two deadlines, 1,909 ms for 1,000; over HTTP/2, which `Bandit` serves on the same listener, its reader gathers DATA frames on a per-frame clock and a - one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline — both + one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline; both measured by review lanes, 2026-09-16, and both closed: over HTTP/2 the reader is asked for less than one frame, so every DATA frame, an empty one included, returns to this clock). Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline @@ -348,26 +348,26 @@ the connection is clean, and an ordinary response is possible. deadline. One residue is the adapter's, stated on the threat model's row with its cost: over HTTP/2 a stream kept open by control frames alone (a WINDOW_UPDATE, or a HEADERS without END_STREAM) is held past the deadline by the adapter's own wait, which nothing outside it - can end through an interface the adapter offers — one frame per deadline holds a stream + can end through an interface the adapter offers: one frame per deadline holds a stream process indefinitely, whatever body then comes is refused, a WINDOW_UPDATE costs thirteen bytes with nothing accumulated, and a HEADERS without END_STREAM writes a warning line per frame to the host's log carrying the client's header bytes. The stream is the adapter's to end, but the **connection** is this package's: `connection_timeout:` (default twice `read_timeout`) closes a connection whose body read has been held that long, with nothing - else on it still within its own deadline, using a `GOAWAY` the client can read — so the + else on it still within its own deadline, using a `GOAWAY` the client can read, so the residue is bounded in duration by this package and in count by `max_concurrent_streams`, and its cost is per connection (the client's other streams still open on it end with the `GOAWAY`; a host multiplexing long streams raises `connection_timeout`). - The `408` is this package's refusal — the JSON-RPC error object + The `408` is this package's refusal: the JSON-RPC error object every refusal carries, with `connection: close` over HTTP/1.1 as for every refusal issued before the body is read (over HTTP/2 the stream ends with the response; the header would be a - malformed one there, and a client answered with it saw a stream reset in place of the refusal - — measured, and closed for every pre-body refusal). Nothing is written to the host's log for a + malformed one there, and a client answered with it saw a stream reset in place of the refusal; + measured, and closed for every pre-body refusal). Nothing is written to the host's log for a `408`: the adapter's own error-level line at its read timeout no longer fires, since the deadline is this package's. A body must declare its length: `transfer-encoding: chunked` is refused with `411` before the body is read, because the adapter reads a chunked body chunk by chunk on a per-chunk clock and a client sending one byte per chunk was served after 43 s under - a 15 s deadline (a review lane, 2026-09-16) — an MCP request is one complete JSON message + a 15 s deadline (a review lane, 2026-09-16); an MCP request is one complete JSON message under the cap, and a chunked body defeats every whole-body bound; no MCP client this package has been run against sends one. For two releases this package passed no deadline at all, and the value in force was `Bandit`'s default for such a call, which the README called "inherited @@ -380,7 +380,7 @@ the connection is clean, and an ordinary response is possible. both transports, is refused by name past 64 levels of nesting before the decoder runs (`-32600`, `400`), and the per-request figure above stays the body's size. -Every vector on the wire — refused, bounded, or delegated to your HTTP server — with the +Every vector on the wire (refused, bounded, or delegated to your HTTP server), with the test that enforces each, is [`docs/threat-model.md`](docs/threat-model.md). Mount this Plug ahead of `Plug.Parsers` or exclude its path: behind the parsers the body is already consumed and every request is a parse error. @@ -388,8 +388,8 @@ and every request is a parse error. **A refusal issued before the body is read ends the connection, and says so.** The `Origin` `403`, the `405`, `authorize/1`'s refusals and the body-cap `413` are all issued before this Plug has read the request body, so each carries `connection: close`. Without it your server reads -that body anyway, on behalf of a caller this Plug has already refused — `Bandit` drains up to -8 MB, waiting up to its read timeout to do it — and past that it gives up and drops the +that body anyway, on behalf of a caller this Plug has already refused (`Bandit` drains up to +8 MB, waiting up to its read timeout to do it), and past that it gives up and drops the connection with nothing said to the client. A refusal issued *after* the body has been read keeps the connection, because by then there is nothing left to drain. What this does not do is get a pipelined second request answered: it cannot, and declining to read a refused caller's @@ -398,12 +398,12 @@ body is the point. `allowed_origins` is separate because the specification makes validating `Origin` a MUST, to prevent DNS rebinding; which origins are legitimate is yours to say. `:any` is available and must be chosen deliberately. The specification also says a locally-running server **SHOULD** bind to -localhost rather than all interfaces — that is your `Bandit` option, above, and this package +localhost rather than all interfaces; that is your `Bandit` option, above, and this package cannot enforce it for you. **What the header requirement does and does not close.** The transport requires an `MCP-Protocol-Version` header on every POST and requires it to match the body, so a request that -establishes no **protocol era** is malformed and refused — that part of the stdio caveat below +establishes no **protocol era** is malformed and refused; that part of the stdio caveat below does not apply here. It does not close the **lifecycle**. `tools/call` over HTTP runs without `initialize` having been @@ -423,7 +423,7 @@ body's `params._meta`; `Mcp-Method`, `Mcp-Name` and `Mcp-Param-{Name}` required requires them and validated against the corresponding body values; `=?base64?…?=` header values decoded before comparison; `Origin` validated against a host-supplied allow list; a body size bound; `405` on non-POST; `404` for an unimplemented method and `200` with a JSON-RPC error for -an unknown tool. Every header is checked in **all** of its values, not the first — a duplicated +an unknown tool. Every header is checked in **all** of its values, not the first: a duplicated header is the smuggling primitive the specification's validation MUST exists to prevent. `Mcp-Param-{Name}` is enforced because `tool_definition/1` passes a schema's `x-mcp-header` @@ -432,7 +432,7 @@ ignoring it gives a client that believes you a silent divergence between the val and the value that ran. **An `x-mcp-header` annotation the specification forbids is the host's fault, not the caller's.** -The revision allows the annotation only on primitive parameters — integer, string, boolean — and +The revision allows the annotation only on primitive parameters (integer, string, boolean) and requires its values to be case-insensitively unique. A schema breaking either is refused, `500` with `-32603`, and the diagnosis names the tool and the offending annotation in the log. Nothing about it reaches the caller: the request was well formed and it is the server that is @@ -442,19 +442,19 @@ type is left alone, because it cannot be judged from the schema and judging it o value instead would turn a wrong-shaped request into a host fault. **Not implemented, by design of the revision.** Sessions, `Mcp-Session-Id`, SSE streaming, and -SSE resumability — all removed from this revision's transport; and the `initialize` / +SSE resumability, all removed from this revision's transport; and the `initialize` / `notifications/initialized` handshake, which `2026-07-28` deleted along with `ping`. Those three are **refused** here rather than merely absent: `404` with `-32601`. The distinction is not pedantry. The package's core is dual-era and its `initialize` clause deliberately -outranks `_meta`, because over stdio an `initialize` *is* the era discriminator — so before this +outranks `_meta`, because over stdio an `initialize` *is* the era discriminator; so before this was refused at the transport, an HTTP caller declaring `2026-07-28` could send `initialize` and receive `200` with `protocolVersion: "2025-11-25"`, a different revision's version number, while this section said it was not implemented. The refusal lives in the transport and not in the core because HTTP is the carrier that stamps every request modern; stdio's dual-era rule is untouched. **Not implemented, and yours.** Binding to localhost (a `Bandit` option), TLS, request timeouts -and connection limits (your HTTP server's settings, not this Plug's), and authentication — +and connection limits (your HTTP server's settings, not this Plug's), and authentication: `authorize/1` is where you put it, and it is required precisely so the decision is yours. ## What it speaks @@ -466,7 +466,7 @@ one legacy revision. |---|---|---| | opens with | any request, or `server/discover` | `initialize`, or `_meta` naming it | | version travels in | `params._meta` on every request | the `initialize` params, or `params._meta` | -| session | none; each request stands alone | tracked, not enforced — see below | +| session | none; each request stands alone | tracked, not enforced; see below | | `ping` | removed from the revision, refused | answered | | result envelope | `resultType` and `_meta` `serverInfo` | neither; both are `2026-07-28` additions | @@ -474,36 +474,36 @@ one legacy revision. `prompts/list`, `prompts/get`, `tools/call`, `shutdown`, `exit` at both eras; `initialize` and `notifications/initialized` at legacy only. -**A revision, not a carrier, decides the semantics.** `params._meta` — the request's `_meta` +**A revision, not a carrier, decides the semantics.** `params._meta` (the request's `_meta` lives inside `params`, the schema's one position; a `_meta` at the top level of the request is -refused as invalid params, not read as a fallback — decides only that a request is served +refused as invalid params, not read as a fallback) decides only that a request is served statelessly. Which revision it *names* then decides the method table and the result envelope, so a `ping` declaring `2025-11-25` through `_meta` is answered and its result carries no `resultType`. This matters because `-32022` tells a client to pick from `supported` -— which lists `2025-11-25` — and retry the request, so a `_meta` naming the legacy revision is +(which lists `2025-11-25`) and retry the request, so a `_meta` naming the legacy revision is a message this server asks clients to send. **Two exceptions, and they are exceptions to the row above.** `server/discover` and `initialize` are matched *before* the revision switch, so neither is affected by what a `_meta` declares. `server/discover` is matched first on purpose: **on stdio it is the era probe**, sent by a client that does not yet know what it is talking to, and it is answered bare. Its result -is the `2026-07-28` `DiscoverResult` in full — `supportedVersions`, `capabilities`, -`resultType`, `ttlMs`, `cacheScope`, and the server's identity in `_meta` — because a client +is the `2026-07-28` `DiscoverResult` in full (`supportedVersions`, `capabilities`, +`resultType`, `ttlMs`, `cacheScope`, and the server's identity in `_meta`), because a client reading a bare result has grounds to classify the server as legacy. `initialize` is the legacy opener and its result is legacy-shaped. **Over HTTP there is no era probe:** every POST must carry `mcp-protocol-version`, a headerless `server/discover` is refused like any -other request, and the transport advertises only the revision it serves — `supportedVersions` +other request, and the transport advertises only the revision it serves: `supportedVersions` is `["2026-07-28"]` there. Dual-era is a stdio fact. **The session is tracked, not enforced.** Nothing in this package refuses a request because `initialize` has not been seen: every method it implements is served bare, `tools/call` -included — and `tools/call` executes through the host's dispatch. On stdio that is defensible, +included, and `tools/call` executes through the host's dispatch. On stdio that is defensible, because whoever can write to the transport already has the host's privileges. **On any transport where that is not true, refusing unestablished callers is the host's job, and this package does not do it for you.** A request naming a revision the server does not support gets `UnsupportedProtocolVersionError` -(**`-32022`**) listing what it does support. **`2024-11-05` is not supported** — it predates +(**`-32022`**) listing what it does support. **`2024-11-05` is not supported**: it predates the two chosen revisions. **JSON-RPC batching is refused.** It was added in `2025-03-26` and removed in `2025-06-18`, so @@ -520,16 +520,16 @@ typed; both ship together or neither does. | revision | suite totals (scored server scenarios) | claimed-surface totals | measured | | -- | -- | -- | -- | -| `2026-07-28` | **16 / 37** | **5 / 6** — `server-stateless`: 21 of 30 checks pass, 5 are skipped (no subscription capability, by decision), 4 need diagnostic tools this harness does not invent | 2026-09-15, `tools/conformance.sh` | +| `2026-07-28` | **16 / 37** | **5 / 6**; `server-stateless`: 21 of 30 checks pass, 5 are skipped (no subscription capability, by decision), 4 need diagnostic tools this harness does not invent | 2026-09-15, `tools/conformance.sh` | | `2025-11-25` over HTTP | **0 / 30** | 0 / 5 | 2026-09-15, `tools/conformance.sh` | **Suite totals do not hide the failures.** The twenty-one `2026-07-28` failures are surfaces -this package holds out by decision — completion, content types beyond text, progress -notifications — and scenarios that need diagnostic tools this harness does not invent +this package holds out by decision (completion, content types beyond text, progress +notifications) and scenarios that need diagnostic tools this harness does not invent (subscriptions are skipped checks, not failures), each named with its reason word in `conformance/baseline-2026-07-28.yml`; the suite exits 1 on a regression *or* on a baselined scenario that starts passing (the resource -and prompt scenarios left the file the day they passed). A scenario passes when none of its checks is `FAILURE` or `WARNING` — +and prompt scenarios left the file the day they passed). A scenario passes when none of its checks is `FAILURE` or `WARNING`, the suite's rule under `--expected-failures`, which ticks two fewer scored scenarios than its plain console summary. **Claimed-surface totals** count only the six scenarios named in `conformance/README.md`: `server/discover` and the stateless rules, `tools/list`, @@ -538,69 +538,69 @@ in `conformance/README.md`: `server/discover` and the stateless rules, `tools/li serves `2026-07-28` only, and `2025-11-25` lives on stdio, which the suite cannot drive (it has no stdio server mode). `conformance/README.md` has the rest. -**Reproduce it:** `tools/conformance.sh` — one command, for anyone with Node ≥ 22 and +**Reproduce it:** `tools/conformance.sh`, one command, for anyone with Node ≥ 22 and `python3`. **The trade, stated:** this package has two dependencies; producing this number costs a second toolchain, so the step runs in a CI job of its own (`conformance.yml`, Node 22 pinned) and never in the local gate, which stays the sixteen steps a contributor with Elixir -and Erlang runs green with nothing else installed (Dialyzer among them, on the OTP binary — its +and Erlang runs green with nothing else installed (Dialyzer among them, on the OTP binary; its PLT is built once per machine, about a minute, and kept under `_build`). The gate needs hex.pm (two steps resolve dependencies); of those, the dependency audit is the one that refuses an answer hex gives -without reaching the registry — it says NOT MEASURED rather than passing from the cache, and +without reaching the registry: it says NOT MEASURED rather than passing from the cache, and CI requires the measurement. The CI job fails loudly when the toolchain is absent; it never skips. ## The connectome -The package exports a composed system's call graph — its wiring diagram — twice, and diffs the -two. Every export is canonical JSON that names the digest it is hashed with — SHA-256 unless -the host chooses SHA-384 or SHA-512 by option — so the same graph gives the same bytes whoever +The package exports a composed system's call graph (its wiring diagram) twice, and diffs the +two. Every export is canonical JSON that names the digest it is hashed with (SHA-256 unless +the host chooses SHA-384 or SHA-512 by option), so the same graph gives the same bytes whoever wrote it, and a verifier reads the algorithm from the bytes ([`docs/connectome-canonical.md`](docs/connectome-canonical.md); the package's whole cryptographic posture, and what a FIPS-mode host needs from it, are [`docs/crypto-posture.md`](docs/crypto-posture.md) and [`docs/fips.md`](docs/fips.md)). -- **Declared** — `BeamMCP.Connectome.Declared.build/1` reads what *can* happen: the catalog, +- **Declared**: `BeamMCP.Connectome.Declared.build/1` reads what *can* happen: the catalog, the call edges of the modules in scope from their beams (OTP's `:xref`), and a grouping of modules by OTP application. Beside the graph it returns a completeness bound: every dynamic dispatch site, callee outside the scope and unreadable entry, enumerated rather than guessed. -- **Observed** — `BeamMCP.Connectome.Observed` reads what *did* happen: a `:telemetry` span on +- **Observed**: `BeamMCP.Connectome.Observed` reads what *did* happen: a `:telemetry` span on the one dispatch site and a host-started ETS collector, plus an optional, off-by-default, - guarded tracer for module-level edges (`BeamMCP.Connectome.Tracer`). Edge identity only — + guarded tracer for module-level edges (`BeamMCP.Connectome.Tracer`). Edge identity only: never a payload byte. The collector's per-call cost is measured and gated at 1.5 µs per - `tools/call` — the owner's ceiling, set 2026-09-14 with its reasoning in `bench/overhead.exs`; + `tools/call`: the owner's ceiling, set 2026-09-14 with its reasoning in `bench/overhead.exs`; a ceiling on an optional feature and not a performance promise. -- **Canonical bytes** — `BeamMCP.Connectome.Canonical` writes RFC 8785-style key order, NFC +- **Canonical bytes**: `BeamMCP.Connectome.Canonical` writes RFC 8785-style key order, NFC strings and one float rule, specified in [`docs/connectome-canonical.md`](docs/connectome-canonical.md) completely enough that three blind re-derivations reproduced the hash. -- **The diff** — `BeamMCP.Connectome.Diff.run/3` puts every edge of either graph in exactly - one of four classes — declared and observed, declared and never observed (dead authority), - observed but undeclared (a drift finding), changed sign — with ten coverage counts the +- **The diff**: `BeamMCP.Connectome.Diff.run/3` puts every edge of either graph in exactly + one of four classes: declared and observed, declared and never observed (dead authority), + observed but undeclared (a drift finding), changed sign. It gives ten coverage counts the consumer divides. [`docs/connectome-diff.md`](docs/connectome-diff.md). **On the wire, as a host chooses.** `BeamMCP.Connectome.Surface` gives a host three read-only -resources — `connectome://declared`, `connectome://observed`, `connectome://diff` — to put in +resources (`connectome://declared`, `connectome://observed`, `connectome://diff`) to put in its own catalog, and `call/2` for the one `:observe` tool a host that exposes tools only writes itself (the package holds no tool; the spec to copy is in the moduledoc); each answers the canonical bytes, byte-identical to the file export (the tool carries them verbatim under -`bytes` with their hash beside, keyed by the algorithm's name — `sha256` unless the host's +`bytes` with their hash beside, keyed by the algorithm's name, `sha256` unless the host's `algorithm:` says otherwise), and nothing else. The host's `read_resource/1` and dispatch delegate to `read/2` and `call/2` with the builder's options, the collector's name and the consumer's window. Read-only by construction and by test: the package's state is compared term for term before and after a call (the tool's own call is a dispatch, which a -running collector records like any other — the moduledoc says so). Nothing else on the wire -moves when a host adds them — a recording of the five advertising methods, on the core (what +running collector records like any other; the moduledoc says so). Nothing else on the wire +moves when a host adds them: a recording of the five advertising methods, on the core (what stdio writes) and through the HTTP transport, before and after, differs by exactly the entries. -**What it never does.** It populates no sign — `:allow`, `:deny`, `:hold` and `:ungoverned` are -a consumer's to write, and the package writes only `:unset` — signs no finding, holds no key and decides +**What it never does.** It populates no sign (`:allow`, `:deny`, `:hold` and `:ungoverned` are +a consumer's to write, and the package writes only `:unset`), signs no finding, holds no key and decides no authority; a census test over `lib/` holds that. It claims no MCP capability the specification does not define: neither protocol revision has a topology primitive, so nothing -on the wire changes and no capability is invented — `connectome://` is a URI scheme of this +on the wire changes and no capability is invented; `connectome://` is a URI scheme of this package's own, served by the resources primitive like any other resource. [`livebooks/connectome.livemd`](https://github.com/ScriptKittyOS/beam_mcp/blob/main/livebooks/connectome.livemd) renders the declared graph, the -observed graph with its weights and the diff, from the JSON export alone — it installs Kino +observed graph with its weights and the diff, from the JSON export alone: it installs Kino and a JSON decoder and no `beam_mcp`, so a reader with only the export sees what a reader with the package sees. The vocabulary is in [`docs/connectome.md`](docs/connectome.md); the collector, the tracer and its stated threat model in @@ -609,7 +609,7 @@ collector, the tracer and its stated threat model in ## What this package is, and is not **Shipping now.** The protocol core for `2026-07-28` and `2025-11-25`; the stdio and stateless -Streamable HTTP transports; JSON Schema validation of tool arguments; the catalog contract — +Streamable HTTP transports; JSON Schema validation of tool arguments; the catalog contract: tools, resources and prompts declared by the host (`BeamMCP.ToolSpec`, `BeamMCP.ResourceSpec` and `BeamMCP.ResourceTemplateSpec`, `BeamMCP.PromptSpec`), tools dispatched through the host's function, resources read and prompts rendered through its two optional callbacks, the resource @@ -620,7 +620,7 @@ the host asserts (`BeamMCP.Connectome.Canonical.signature/3`); the connectome sp that renders it and the read-only surface a host puts on the wire (`BeamMCP.Connectome.Surface`); and reachability queries over a graph (`BeamMCP.Connectome.Reach`: can an entry reach an effect, can it do so without crossing a -gate — with a witness path made of the graph's own edges — does a gate dominate an effect, and +gate (with a witness path made of the graph's own edges), does a gate dominate an effect, and which nodes every path must cross; on OTP's `:digraph`, dominators by Lengauer–Tarjan, no new dependency; [`docs/connectome-reach.md`](docs/connectome-reach.md)). Every module named in this paragraph is held by a census to the set compiled from `lib/`, and any module named in @@ -631,22 +631,23 @@ federation seam for merging graphs from several nodes; and effective connectivit observed graph weighted into the declared one. Multi-round-trip requests are decided *against* ([`docs/will-not-implement.md`](docs/will-not-implement.md), entry 12). -**Scheduled.** In that order, each at the minor position while the package is `0.x`: the -federation seam, then effective connectivity. -`1.0.0` follows once the public API and the stated threat model have each survived a full -minor release unchanged. Who decides, how a change lands, and what happens if the one +**Scheduled.** `1.0.0` follows once the public API and the stated threat model have each +survived a full minor release unchanged. After it, in that order and each an addition at the +minor position: the federation seam, then effective connectivity, then the Tasks extension of +the `2026-07-28` revision (not built today, and not refused either). +[`docs/roadmap.md`](docs/roadmap.md) holds the order. Who decides, how a change lands, and what happens if the one maintainer stops: [`docs/governance.md`](docs/governance.md) and [`docs/succession.md`](docs/succession.md), stated as they are. **Deliberately out.** Tools, domain and policy; risk tiers, approvals, receipts and egress -masking; authority — the verdict on an edge, the key that signs it, the decision that acts on +masking; authority: the verdict on an edge, the key that signs it, the decision that acts on it. These are absent by decision, not by immaturity: they live on the consumer's side of a boundary this project chose on its first day, and the package exists partly to keep them there. A small surface can look unfinished from the outside; this one is a commodity layer that says which it is. The list is derived, not typed: the thesis sentence at the top of this README, the census test that no line under `lib/` writes a sign other than `:unset`, and a test that no line under `lib/` names a receipt, an approval, a risk tier or egress. The full -boundary — twelve entries, each with the test that enforces it by path and by name — is +boundary (twelve entries, each with the test that enforces it by path and by name) is [`docs/will-not-implement.md`](docs/will-not-implement.md), held to the tests in both directions by a census of its own; a request to cross it is answered by pointing there. What the package defends against on the wire, and what it hands to the server or the host, is @@ -657,7 +658,7 @@ the package defends against on the wire, and what it hands to the server or the Pre-1.0. The API may change. Known gaps are listed above and in [`CONVENTIONS.md`](https://github.com/ScriptKittyOS/beam_mcp/blob/main/CONVENTIONS.md), which is not shipped in the package and so is linked rather than named. It also -records how this package is developed — the gate takes no baseline, a probe's population is +records how this package is developed: the gate takes no baseline, a probe's population is derived the way the checked mechanism derives it, and CI is unproven until a run exists. Consumers today: Ultraviolet, and Trinity as a candidate under its own evaluation. @@ -670,24 +671,24 @@ Apache-2.0. See `LICENSE`, and `NOTICE` for attribution. A plain-language statement for the compliance reader, of fact where it is about this package and of the maintainer's reading where it is about the regulations. **It is not legal advice, -and it has not been reviewed by counsel** — that sentence leaves this paragraph only when one +and it has not been reviewed by counsel**. That sentence leaves this paragraph only when one has. `beam_mcp` is open-source software, published publicly under the Apache License 2.0 on GitHub and on hex.pm. **Its own code contains no encryption.** Its only cryptographic operation is a -SHA-2 message digest — SHA-256 by default, SHA-384 or SHA-512 by option — computed by +SHA-2 message digest (SHA-256 by default, SHA-384 or SHA-512 by option), computed by Erlang/OTP's `:crypto` (OpenSSL on the builds the maintainers run) at one call site, used solely for integrity hashing of canonical bytes ([`docs/crypto-posture.md`](docs/crypto-posture.md)); it holds no key material, and a census in its test suite refuses any other `:crypto.` call and any key by name. The optional HTTP transport's dependencies (`plug`, `bandit`) bring their -own libraries — `plug_crypto` carries AES — into an integrator's build; those are outside this +own libraries (`plug_crypto` carries AES) into an integrator's build; those are outside this package's tarball and are the integrator's classification, as the last sentence says. Under **15 CFR 734.7(a)(4)**, unclassified -software made available to the public without restriction — "posting on the Internet on -sites available to the public" — is published and thus not subject to the Export +software made available to the public without restriction ("posting on the Internet on +sites available to the public") is published and thus not subject to the Export Administration Regulations; the exception in **734.7(b)** for published *encryption* software classified under ECCN 5D002 (which, with **742.15(b)**, keeps such software subject to the EAR unless its source is publicly available and, for "non-standard cryptography", notified) does -not reach a library whose only cryptographic function is a standard message digest — the +not reach a library whose only cryptographic function is a standard message digest, the maintainer's reading; the regulation's own definitions (15 CFR 772.1) limit "cryptography" to transformations using "secret parameters" and "encryption software" to programs that provide "encryption functions or confidentiality of information", and an unkeyed digest is neither. This software is not diff --git a/SECURITY.md b/SECURITY.md index db290b24..6249d636 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 Report privately through **[GitHub Security Advisories](https://github.com/ScriptKittyOS/beam_mcp/security/advisories/new)**. Please do not open a public issue for a suspected vulnerability. A report that cannot go -through the form — no GitHub account, or a concern about the form itself — goes to +through the form (no GitHub account, or a concern about the form itself) goes to **ayla@scriptkittyos.com**, the maintainer, and is answered on the same commitments as below. Useful in a report: the protocol revision and transport, a request that triggers it, what you @@ -22,7 +22,7 @@ be kept rather than one that sounds reassuring: - **Acknowledgement within 7 days.** If you have heard nothing after 7 days, assume the report did not arrive and open a public issue saying only that you are waiting on a security - response — no detail; a reporter without a GitHub account mails the address above again, + response, no detail; a reporter without a GitHub account mails the address above again, which is the only fallback that route has. - **An assessment within 30 days** of acknowledgement: whether it is in scope, and if so a rough severity and intended fix window. If it will take longer, you will be told that @@ -39,14 +39,14 @@ product, a manufacturer or a steward. ## Severity, in this package's terms The assessment names one of four levels, by what a defect lets a client do to the host that -embeds this package — not by a generic score. The fix window is the intent stated at +embeds this package, not by a generic score. The fix window is the intent stated at assessment; the 30-day assessment window above is the commitment. | level | what it means here | fix window (intent) | |---|---|---| | **Critical** | A client reaches dispatch with arguments the advertised schema forbids, or reaches a tool the catalog did not advertise, or crashes the host's server process with one message. | A patch release of the supported minor carrying only the fix, as soon as it can be cut. | | **High** | A client is served under a protocol revision it did not declare, or reads server internals across the wire (a stack, a path, a secret in a fault), or desynchronises the reader so one message is read as another. | The next scheduled release, or sooner if a workaround cannot be stated. | -| **Medium** | A bound (line, body, nesting, connection) can be exceeded or evaded so the host buffers without limit, where a stated workaround exists (a transport option where one exists — the HTTP transport's timeouts — or a limit in front of the package). | A scheduled release; the workaround published at assessment. | +| **Medium** | A bound (line, body, nesting, connection) can be exceeded or evaded so the host buffers without limit, where a stated workaround exists (a transport option where one exists, the HTTP transport's timeouts, or a limit in front of the package). | A scheduled release; the workaround published at assessment. | | **Low** | Wrong error codes or messages, a refusal that names more than it should, a documented behaviour the package does not quite match. | With other work; recorded in the changelog when fixed. | The examples are this package's own surface (see *In scope*); a report about a host's tool or @@ -56,7 +56,7 @@ an injected function is out of scope at any level. Advisories are published from this repository's GitHub Security Advisories. GitHub is a CVE Numbering Authority (CNA) for repositories it hosts, so a CVE is requested from the advisory -draft and assigned before publication when the defect warrants one — Critical and High always +draft and assigned before publication when the defect warrants one: Critical and High always do; Medium when a consumer needs an identifier to act on; Low rarely. A published advisory reaches the GitHub Advisory Database and OSV, the source hex.pm's registry advisories are fed from and `mix hex.audit` reads, so a consumer running the audit sees it against their lock @@ -66,23 +66,23 @@ file without this project telling them. The package's own code, `lib/`: -- **Protocol handling** — malformed, hostile or ambiguous JSON-RPC that crashes the server, +- **Protocol handling**: malformed, hostile or ambiguous JSON-RPC that crashes the server, bypasses validation, or is answered under the wrong protocol revision. -- **Framing and bounds** — input that escapes the line or body limits, or desynchronises the +- **Framing and bounds**: input that escapes the line or body limits, or desynchronises the reader so one message is interpreted as another. -- **Schema validation** — arguments that reach dispatch despite violating the schema the +- **Schema validation**: arguments that reach dispatch despite violating the schema the catalog advertised, including key- or type-confusion between the validated form and the dispatched form. -- **Era confusion** — a request served under a protocol revision other than the one it +- **Era confusion**: a request served under a protocol revision other than the one it declared. -- **Information disclosure across the wire boundary** — server internals reaching a client +- **Information disclosure across the wire boundary**: server internals reaching a client that should not see them. ## Out of scope - **What a host's tools do.** This package validates and routes; it does not execute. A tool that deletes files when asked is the host's design, not a defect here. -- **Anything the host injects** — the catalog, the dispatch function, and whatever they reach. +- **Anything the host injects**: the catalog, the dispatch function, and whatever they reach. - **Transport security.** stdio is a local pipe; confidentiality and authentication of that channel belong to whatever spawns the process. - **Denial of service through legitimate volume.** Bounds exist to stop unbounded buffering, @@ -99,12 +99,13 @@ ignorance of them: ## Supported versions Fixes land on the latest published minor. Earlier minors are not backported, and while the -package is `0.x` a fix may arrive in a release that also carries a wire change — the changelog +package is `0.x` a fix may arrive in a release that also carries a wire change; the changelog entry says so when it does. | version | supported | |---|---| -| `0.9.x` | yes | +| `0.10.x` | yes | +| `0.9.x` | no, superseded | | `0.8.x` | no, superseded | | `0.7.x` | no, superseded | | `0.6.x` | no, superseded | diff --git a/UPGRADING.md b/UPGRADING.md index 420ed7af..c2c509a0 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -7,14 +7,14 @@ SPDX-License-Identifier: Apache-2.0 From any `0.x` to the next, and what `1.0` will ask. The policy this follows is `docs/api-stability.md`; the surface it applies to is `docs/public-api.txt`; every break below -has its full entry in `CHANGELOG.md` under the heading the table names — from `0.5.0` on with +has its full entry in `CHANGELOG.md` under the heading the table names: from `0.5.0` on with a "how to tell whether you are affected" sentence, and from `0.6.0` on under a heading that says **BREAKING**, which the census requires. ## The rule for `0.x` -Breaks land at the **minor** position and nowhere else. Pin `~> 0.9.0` (the current minor, -three numbers), not `~> 0.9`: the tighter pin stops at the next minor, which is where the +Breaks land at the **minor** position and nowhere else. Pin `~> 0.10.0` (the current minor, +three numbers), not `~> 0.10`: the tighter pin stops at the next minor, which is where the next documented break can be, so a routine `mix deps.update` never carries you across one. To move a minor: read the release's rows below and their CHANGELOG entries, apply each "how to tell" sentence to your host, then raise the pin. @@ -23,28 +23,28 @@ to tell" sentence to your host, then raise the pin. | release | where it breaks | CHANGELOG heading | what to do | | --- | --- | --- | --- | -| `0.2.0` | the wire, for clients whose `_meta` names `2025-11-25` | "Changed — two fields are REMOVED from results for legacy-declared requests" | results no longer carry `resultType` or `_meta.io.modelcontextprotocol/serverInfo`; a client reading either must stop | -| `0.3.0` | the wire, on `tools/list` — and an addition placed at the minor by policy | "Added — `ttlMs` and `cacheScope` on `tools/list`" and "Added — stateless Streamable HTTP transport" | `tools/list` results carry the `ttlMs`/`cacheScope` fields `2026-07-28` requires; the HTTP transport arrives as an optional dependency pair (`plug`, `bandit`) — a stdio-only host changes nothing, an HTTP host supplies the two options that have no defaults | -| `0.4.0` | the host contract | "Changed — BREAKING, and it breaks a host contract rather than the wire" | `BeamMCP.ToolCatalog` is replaced by `BeamMCP.Catalog`, `all/0` by `capabilities/0`; every catalog implementation changes | -| `0.5.0` | the wire, the exported bytes, the host contract | "Changed — BREAKING: the request `_meta` …", "Changed — BREAKING: the sign vocabulary …", and the structs requirement under the "Added — the resources primitive" and "Added — the prompts primitive" entries | a request's `_meta` is read at `params._meta` and refused at the top level; the sign vocabulary renames the one sign the package writes and `schema_version` becomes `2`; a catalog's `resources` and `prompts` lists must hold the package's structs | -| `0.6.0` | the exported bytes | "Changed — BREAKING (the exported bytes): the canonical envelope names its algorithm; `schema_version` 3 …" | a verifier that pins `schema_version: 2` or hashes without reading the algorithm must be updated (`docs/connectome-canonical.md`, "Versions") | +| `0.2.0` | the wire, for clients whose `_meta` names `2025-11-25` | "Changed: two fields are REMOVED from results for legacy-declared requests" | results no longer carry `resultType` or `_meta.io.modelcontextprotocol/serverInfo`; a client reading either must stop | +| `0.3.0` | the wire, on `tools/list`, and an addition placed at the minor by policy | "Added: `ttlMs` and `cacheScope` on `tools/list`" and "Added: stateless Streamable HTTP transport" | `tools/list` results carry the `ttlMs`/`cacheScope` fields `2026-07-28` requires; the HTTP transport arrives as an optional dependency pair (`plug`, `bandit`): a stdio-only host changes nothing, an HTTP host supplies the two options that have no defaults | +| `0.4.0` | the host contract | "Changed: BREAKING, and it breaks a host contract rather than the wire" | `BeamMCP.ToolCatalog` is replaced by `BeamMCP.Catalog`, `all/0` by `capabilities/0`; every catalog implementation changes | +| `0.5.0` | the wire, the exported bytes, the host contract | "Changed (BREAKING): the request `_meta` …", "Changed (BREAKING): the sign vocabulary …", and the structs requirement under the "Added: the resources primitive" and "Added: the prompts primitive" entries | a request's `_meta` is read at `params._meta` and refused at the top level; the sign vocabulary renames the one sign the package writes and `schema_version` becomes `2`; a catalog's `resources` and `prompts` lists must hold the package's structs | +| `0.6.0` | the exported bytes | "Changed (BREAKING, the exported bytes): the canonical envelope names its algorithm; `schema_version` 3 …" | a verifier that pins `schema_version: 2` or hashes without reading the algorithm must be updated (`docs/connectome-canonical.md`, "Versions") | | `0.9.0` | nothing breaks: two additions placed at the minor by policy | "Added: the server seam, `:server` on both transports, a module above the core" and "Added: the scheme beside the signature, `scheme:` and `key_id:` in `signature/3`'s return" | a host that passes no `:server` and reads no `scheme:` changes nothing; a host putting a wrapper above the core passes its module under `:server` (`@behaviour BeamMCP.Server`; HTTP reaches `new/1` and `handle_message/2`, stdio those and `shutdown?/1`); a host matching `signature/3`'s return exactly reads five members now, the two new ones `nil` unless it passed them | `0.6.0` also restates the OTP floor's reason (OTP 27's trace sessions, which the connectome -tracer now runs in) — not a new floor: 27 was already the floor — and changes what the tracer +tracer now runs in), not a new floor (27 was already the floor), and changes what the tracer does beside a host's own tracer (`docs/connectome-observed.md`); neither removes, renames or hides a public entry. -**`0.7.0` has no row: it breaks nothing.** It adds the signer seam — `BeamMCP.Signer` (one -callback, `sign/2`), `BeamMCP.Signer.None` and `BeamMCP.Connectome.Canonical.signature/3` — -under "Added — the signer seam" in the CHANGELOG. A host that does not sign changes nothing; +**`0.7.0` has no row: it breaks nothing.** It adds the signer seam, `BeamMCP.Signer` (one +callback, `sign/2`), `BeamMCP.Signer.None` and `BeamMCP.Connectome.Canonical.signature/3`, +under "Added: the signer seam" in the CHANGELOG. A host that does not sign changes nothing; one that does adds the separate package `beam_mcp_signer` and passes its module and key to `signature/3`. Raise the pin to `~> 0.7.0` when you take it; `~> 0.6.0` stops before it by the rule, not because anything moved. **`0.8.0` has no row either: it is the quiet minor.** No public entry was added, removed, -renamed, hidden or changed in arity — `docs/public-api.txt` is line for line `0.7.0`'s, and the -release step wrote nothing into it — and no wire byte or envelope byte moved. What changed is +renamed, hidden or changed in arity (`docs/public-api.txt` is line for line `0.7.0`'s, and the +release step wrote nothing into it), and no wire byte or envelope byte moved. What changed is instruments (the gate diffs the baseline against `origin/main`; the pull-request summary waits for running legs) and pages (the Scorecard's measured figures on the governance page). Raise the pin to `~> 0.8.0`. **A host on `beam_mcp_signer` waits for that package's release that @@ -63,13 +63,22 @@ returns, copied from the host's options and verified by nothing here; the canoni `schema_version` do not move. Raise the pin to `~> 0.9.0`. A host on `beam_mcp_signer` `0.1.1` (requirement `~> 0.7`) resolves beside `{:beam_mcp, "~> 0.9.0"}` without a signer release. +**`0.10.0` has no row: it is the quiet minor again.** No public entry was added, removed, +renamed, hidden or changed in arity (`docs/public-api.txt` did not move, and the release step +wrote nothing into it), and no wire byte or envelope byte moved. What changed is instruments +and pages: the suite runs in FIPS mode in CI on the validated OpenSSL provider, each release +carries an attested SBOM, `docs/fips.md` reads measurements, and the pages are free of em +dashes, the CHANGELOG's released headings included (the quotations below and above follow them). +Raise the pin to `~> 0.10.0`. `beam_mcp_signer` `0.1.1` and `0.2.0` (requirement `~> 0.7`) +resolve beside it without a signer release. + ## The road to `1.0.0`, in order Stated here so nobody infers it from a plan's label or a folder's name: -1. **`0.6.0`** — everything since `0.5.0`, the assessability snapshot. One documented break at +1. **`0.6.0`**, everything since `0.5.0`, the assessability snapshot. One documented break at the minor (the exported bytes). -2. **`0.7.0`** — the signer seam, `BeamMCP.Signer` (a behaviour added to the public +2. **`0.7.0`**, the signer seam, `BeamMCP.Signer` (a behaviour added to the public surface; no authority passes through it), the last intentional addition before `1.0.0`. An addition, not a break; `~> 0.6.0` stops at it all the same, by the rule. *Superseded 2026-09-21, appended not edited:* it was not the last: `0.9.0` adds the @@ -77,23 +86,23 @@ Stated here so nobody infers it from a plan's label or a folder's name: from this package before `1.0.0` can be an honest freeze; the stand at `0.8.0` ended for that reason and no other. 3. **`0.8.0`**, the quiet minor. Documentation, the Scorecard's rows, - instrument leftovers. No public entry added, removed, renamed or hidden — the "full minor + instrument leftovers. No public entry added, removed, renamed or hidden: the "full minor release unchanged" the README's `1.0.0` condition requires, measured by `docs/public-api.txt` not moving (the gate now diffs it against `origin/main` on every run). -4. **`0.9.0`**, this release: the `:server` seam on both transports and `scheme:`/`key_id:` +4. **`0.9.0`**: the `:server` seam on both transports and `scheme:`/`key_id:` beside the signature. Two additions, no break; four public entries added; the clock the README's condition names restarts here. -5. **`0.10.0`**, the quiet minor again: instruments and pages (a FIPS leg in CI, the SBOM - attached at release), no public entry moves. +5. **`0.10.0`**, this release, the quiet minor again: instruments and pages (the suite in FIPS + mode in CI, an attested SBOM at release, the em-dash sweep), no public entry moved. 6. **`1.0.0`**, after `0.10.0` has stood: the surface frozen as `docs/api-stability.md` says. ## What `1.0` will ask The `1.0.0` release freezes `docs/public-api.txt` as it stands at that release: from then on an incompatible change to any line of it ships only in a major, after three minors of -`@deprecated` warning. The README states the condition for cutting it — `1.0.0` follows once -the public API and the stated threat model have each survived a full minor release unchanged -— and nothing here adds to it. The cycle that cuts `1.0.0` itself still reads `0.x` in +`@deprecated` warning. The README states the condition for cutting it (`1.0.0` follows once +the public API and the stated threat model have each survived a full minor release unchanged) +and nothing here adds to it. The cycle that cuts `1.0.0` itself still reads `0.x` in `mix.exs`, so it may still use the `0.x` documented-break sentence, as SemVer allows before the first stable release; that release's row will say so if it does. What a `0.x` consumer must do to reach `1.0` is, today, nothing beyond the minors above: no entry is deprecated at diff --git a/conformance/README.md b/conformance/README.md index 09993db4..35451209 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -9,9 +9,9 @@ Runs the official MCP conformance suite (`@modelcontextprotocol/conformance`, pi version in `tools/conformance.sh`) against this package's HTTP transport and publishes two rows per revision, derived from the suite's own `checks.json` and never typed: -- **suite totals** — scored scenarios passed / scored, over the revision's frozen requirement +- **suite totals**: scored scenarios passed / scored, over the revision's frozen requirement set; the failures are not hidden. -- **claimed-surface totals** — the same, over only the scenarios whose methods and tool names +- **claimed-surface totals**: the same, over only the scenarios whose methods and tool names this package says it implements; a reader does not conclude the package fails what it never claimed. The set, by name (`CLAIMED` in `tools/conformance.sh`): `server-stateless` (`server/discover` and the stateless rules), `tools-list`, `tools-call-simple-text`, @@ -22,13 +22,13 @@ per revision, derived from the suite's own `checks.json` and never typed: **The rule the rows use** is the suite's own under `--expected-failures`: a scenario passes when none of its checks is `FAILURE` or `WARNING`; `SKIPPED` and `INFO` do not fail it. The -suite's plain console summary ticks a WARNING-only scenario; the baseline verdict — the one -that can exit 1 — does not, so a hand count of the console's scored ticks reads two higher +suite's plain console summary ticks a WARNING-only scenario; the baseline verdict (the one +that can exit 1) does not, so a hand count of the console's scored ticks reads two higher (18 / 37 on 2026-09-15, the day the prompt scenarios passed) than the rows. The rows follow the verdict. `baseline-.yml` lists every expected failure with a reason word in the comment beside -it — `deliberately-out`, `decided-not-built`, `not-implemented`, `harness` (the suite needs a +it: `deliberately-out`, `decided-not-built`, `not-implemented`, `harness` (the suite needs a diagnostic tool this package cannot honestly serve), `design` (the legacy revision over HTTP: the transport serves `2026-07-28` only, and `2025-11-25` lives on stdio, which the suite cannot drive). The suite exits 1 on an unexpected failure AND on a baselined scenario that now passes, diff --git a/docs/api-stability.md b/docs/api-stability.md index 36b1c9b3..0a7a5c03 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -14,14 +14,14 @@ fails when the surface moves without the record this page requires. **Public is what ex_doc lists.** Every function, macro, callback and type in a module whose `@moduledoc` is present is public unless its `@doc` is `false`. A missing `@doc` is still listed -and still public. Hiding is explicit — `@doc false`, `@moduledoc false` — with one Elixir +and still public. Hiding is explicit (`@doc false`, `@moduledoc false`), with one Elixir convention to know: `@impl true` marks a callback implementation `@doc false` unless `@doc` is set, so a behaviour's callbacks implemented here are off the list. That is why the HTTP -transport's `init` and `call` — the two `Plug` callbacks a host's pipeline reaches, hidden by -their `@impl Plug` — are not entries: the transport being a `Plug` is promised by its module -documentation and by the README, not by this list. A module with `@moduledoc false` is private surface — callable, +transport's `init` and `call` (the two `Plug` callbacks a host's pipeline reaches, hidden by +their `@impl Plug`) are not entries: the transport being a `Plug` is promised by its module +documentation and by the README, not by this list. A module with `@moduledoc false` is private surface: callable, since the BEAM hides nothing, but unpromised: it can change or go in any release with no -entry. Today no module under `lib/` is private — every one of the 26 carries a `@moduledoc` — +entry. Today no module under `lib/` is private (every one of the 26 carries a `@moduledoc`), and `docs/public-api.txt` lists their 128 public entries as of this page's writing, 25 of them with a default argument. @@ -32,42 +32,42 @@ application's documentation chunks and never by hand: It keeps every marker in place, adds a line for what the application has gained, and marks what it has deprecated or lost; it never deletes a line. Its grammar is one line per entry, -`Module kind name/arity`, then `defaults=N` when the function has `N` default arguments — -`run(opts \\ [])` is `run/1 defaults=1`, callable as `run/0` as well, so dropping a default -removes a callable arity and is a change to the entry — then markers: +`Module kind name/arity`, then `defaults=N` when the function has `N` default arguments +(`run(opts \\ [])` is `run/1 defaults=1`, callable as `run/0` as well, so dropping a default +removes a callable arity and is a change to the entry), then markers: | marker | meaning | | --- | --- | | `since=R` | first shipped in release `R` | | `deprecated_since=R` | `@deprecated` first shipped in release `R` | -| `removed_in=R` | left the public surface in release `R` — a deletion, a rename, an arity or defaults change, or a `@doc false` | +| `removed_in=R` | left the public surface in release `R`: a deletion, a rename, an arity or defaults change, or a `@doc false` | `R` is a release number, or the word `Unreleased` while the change waits in the CHANGELOG's Unreleased section; the release that ships it writes its number in -(`MIX_ENV=test mix run -e 'BeamMCP.PublicAPI.release_markers!("0.9.0")'`, one step of cutting +(`MIX_ENV=test mix run -e 'BeamMCP.PublicAPI.release_markers!("0.10.0")'`, one step of cutting a release), and the census refuses a leftover `Unreleased` once that section is empty. That step is the one place that knows the release's number, so it holds the three rules the census cannot: with a `removed_in=Unreleased` line in the file it refuses a patch number, and on `1.x` any number that is not a major. A line -changes state; it is not deleted — with one exception, an entry that comes back after a +changes state; it is not deleted, with one exception: an entry that comes back after a removal, whose `removed_in` is deleted and `since` set again. The tree carries what left and when, and the census reads the tree, never git history. Two hand edits are invisible to a tree-only census, the same as editing any pinned list: a line deleted outright, and a removal marked with the last release's number instead of `Unreleased` (the census reads a released -number as a past cycle's record). Both are a reviewer's line — a `-` line in the diff of -`docs/public-api.txt`, or a `removed_in` that is not `Unreleased` arriving in a change — and +number as a past cycle's record). Both are a reviewer's line (a `-` line in the diff of +`docs/public-api.txt`, or a `removed_in` that is not `Unreleased` arriving in a change) and not this page's promise. -**Promised separately, by their own pages, not by this list:** the wire — which protocol +**Promised separately, by their own pages, not by this list:** the wire: which protocol revisions the transports serve and what each request is answered with (`README.md`); the -exported bytes — the canonical envelope's `schema_version` and its `algorithm`, whose history +exported bytes: the canonical envelope's `schema_version` and its `algorithm`, whose history and verifier consequences are in `docs/connectome-canonical.md` and `docs/connectome-diff.md`; -the host contract — the `BeamMCP.Catalog` behaviour, whose callbacks are entries here, the +the host contract: the `BeamMCP.Catalog` behaviour, whose callbacks are entries here, the `Plug` contract of `BeamMCP.Transport.HTTP`, and the options the transports and the tracer take, documented on their modules and functions; the `:telemetry` events, their measurements and metadata (`docs/connectome-observed.md`); and the tracer's exit reasons, documented on `BeamMCP.Connectome.Tracer`. **Promised by the typespecs, not by this list's names:** the -fields of the public structs and the shapes behind the `@type`s and `@spec`s — a field renamed +fields of the public structs and the shapes behind the `@type`s and `@spec`s. A field renamed or a return shape changed leaves `Module kind name/arity` untouched, so the census does not see it; such a change is a break like any other and is a reviewer's line and a CHANGELOG entry, not a census failure. **Not promised anywhere:** the shape of `inspect/1` output, the text of @@ -79,14 +79,14 @@ layout of the observed collector's ETS rows beyond the `row/0` type **While the package is `0.x`, breaks land at the minor position** and are documented as such. From the release this page ships in, a break is a CHANGELOG heading carrying the word -**BREAKING** whose section carries a **"How to tell whether you are affected"** sentence — the -census holds the Unreleased section to that — and `UPGRADING.md` lists them. Earlier releases +**BREAKING** whose section carries a **"How to tell whether you are affected"** sentence (the +census holds the Unreleased section to that), and `UPGRADING.md` lists them. Earlier releases documented their breaks under headings of their own wording (`0.2.0`'s "two fields are REMOVED", `0.4.0`'s "BREAKING, and it breaks a host contract"); `UPGRADING.md` names each with -its CHANGELOG heading. That is why the README recommends the current minor at three numbers (`~> 0.9.0`) rather than two (`~> 0.9`): the +its CHANGELOG heading. That is why the README recommends the current minor at three numbers (`~> 0.10.0`) rather than two (`~> 0.10`): the tighter pin stops at the next minor, which is where the next documented break can be. A patch release carries no break to the public surface, the wire, the exported bytes or the host -contract — a rule the census cannot check (it does not know which number the next release +contract, a rule the census cannot check (it does not know which number the next release will carry) and the release step can: `release_markers!/1` refuses a patch number while a removal waits. @@ -101,22 +101,22 @@ stated); `UPGRADING.md` carries what a `0.x` consumer must do to reach it. A public entry that is going to leave goes in three steps, each on the record: 1. **`@deprecated`, with the replacement already shipped.** The function carries - `@deprecated "use ..."` — the compiler warns every caller at their compile — the writer + `@deprecated "use ..."` (the compiler warns every caller at their compile), the writer marks its baseline line `deprecated_since=Unreleased`, and the CHANGELOG's Unreleased section names the exact `Module.name/arity` in a bullet that records the deprecation and its replacement. The replacement exists in the same release or an earlier one; a deprecation that points at nothing is not one (a rule of review: the census checks the attribute, the marker and the bullet, not what the message names). Every caller of the - entry inside the package moves to the replacement in the same change — the package + entry inside the package moves to the replacement in the same change; the package compiles with warnings as errors, so its own call to a deprecated function is a failed build, which is the right answer. 2. **Three minors of warning.** Three minor releases ship with the deprecation before the - release that removes the entry: deprecated in `0.6.0`, it may be removed once `mix.exs` — - the latest release — reads `0.8.0`, so `0.6`, `0.7` and `0.8` shipped with the warning and + release that removes the entry: deprecated in `0.6.0`, it may be removed once `mix.exs` + (the latest release) reads `0.8.0`, so `0.6`, `0.7` and `0.8` shipped with the warning and the removal ships in `0.9.0`. An entry deprecated on `0.x` and still present at `1.0.0` is on the `1.0.0` surface and waits like any `1.x` entry: three minors of the new major (`mix.exs` at `1.3.0`). The same count holds on `1.x`, where the release that carries the - removal is a major: the census cannot check that number, and the release step does — + removal is a major: the census cannot check that number, and the release step does: `release_markers!/1` refuses a `1.x` minor while a removal waits. 3. **Removal, on the record.** The writer marks the line `removed_in=Unreleased`, and the CHANGELOG names the entry again, in the release that removes it. @@ -124,8 +124,8 @@ A public entry that is going to leave goes in three steps, each on the record: **A `0.x` break may skip the wait, said in so many words.** While the package is `0.x` an entry may be removed, renamed or have its arity or defaults changed without three minors of `@deprecated` if the CHANGELOG bullet that names it says it is a **documented break at the -minor** — the README's rule for `0.x`, "documents breaks at the minor position", in a fixed -form — under a **BREAKING** heading whose section +minor** (the README's rule for `0.x`, "documents breaks at the minor position", in a fixed +form) under a **BREAKING** heading whose section carries the "How to tell whether you are affected" sentence. A first-time `@deprecated` in the same change as the deletion does not count as the wait. From `1.0.0` there is no such skip: the census reads `mix.exs`'s major, so the sentence stops counting the moment `1.0.0` has @@ -137,7 +137,7 @@ a public module, takes it off the surface a consumer can rely on, and is treated deletion: `removed_in` on the line, the CHANGELOG naming it, and the same wait or the same `0.x` sentence. There is no silent hide. -**How this compares.** Elixir itself deprecates in three steps — a soft deprecation in the +**How this compares.** Elixir itself deprecates in three steps: a soft deprecation in the CHANGELOG and docs, then warnings once the alternative "MUST exist for AT LEAST THREE minor versions", then removal "only … on major releases" (its "Compatibility and deprecations" page); Phoenix, Ecto and Plug record deprecations in CHANGELOG sections of their own. This @@ -152,7 +152,7 @@ The census compares the compiled application's documentation chunks with `docs/public-api.txt` and both with the CHANGELOG's Unreleased section. A documented public entry may change only if **all** of the following hold in the same change: -1. The baseline moves with it — a new line for an addition, a marker for a deprecation or a +1. The baseline moves with it: a new line for an addition, a marker for a deprecation or a removal (the writer does both). 2. The CHANGELOG's Unreleased section names the exact `Module.name/arity`, the arity ending there (a type or callback may carry its `t:`/`c:` prefix). Not the name alone; not the @@ -162,7 +162,7 @@ entry may change only if **all** of the following hold in the same change: its line, and the bullet that names it records a deprecation; - **removed, renamed, or arity or defaults changed:** `removed_in` on its line, and either `deprecated_since` naming a release with three minors shipped since (step 2's count), - or — on `0.x` only — the bullet says *documented break at the minor* under a BREAKING + or (on `0.x` only) the bullet says *documented break at the minor* under a BREAKING heading with the how-to-tell sentence; - **docs-hidden:** as a removal. diff --git a/docs/connectome-canonical.md b/docs/connectome-canonical.md index 1e81abec..00b76c62 100644 --- a/docs/connectome-canonical.md +++ b/docs/connectome-canonical.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# The connectome — canonical bytes +# The connectome: canonical bytes This page is the contract for the bytes a connectome is hashed and signed over. It is complete enough that a verifier can be written in another language from this page alone, without @@ -12,7 +12,7 @@ two disagree, the page is right and the code is the defect. Vocabulary is in `docs/connectome.md`. What is hashed is the **declared form** of a graph: its schema version, the algorithm it is hashed with, its nodes and its edges. Weights are -not in it — a weight is a measurement, and the declared hash is a claim about wiring — and +not in it (a weight is a measurement, and the declared hash is a claim about wiring) and travel in a separate **sidecar** that is never hashed with the declared bytes. ## Layout @@ -23,8 +23,8 @@ The bytes are UTF-8 JSON with no insignificant whitespace, written under these r `"schema_version"`, then `"algorithm"`, then `"nodes"`, then `"edges"`. This is the one place the order is fixed rather than sorted, so the version is the first thing a reader meets and the digest the second. The schema version is the integer `3` (see *Versions* - below). The algorithm is one of the three strings `"sha256"`, `"sha384"`, `"sha512"` — - the name under which the digest is known to `:crypto`, lowercase, no hyphen — and it is + below). The algorithm is one of the three strings `"sha256"`, `"sha384"`, `"sha512"` + (the name under which the digest is known to `:crypto`, lowercase, no hyphen), and it is what rule 9 hashes the bytes with. **The nodes and edges members do not depend on the algorithm:** two envelopes of one graph under two digests differ in that member's value and in nothing else, so the bytes under another digest are derived from the bytes under @@ -33,20 +33,20 @@ The bytes are UTF-8 JSON with no insignificant whitespace, written under these r changed. A name outside the three is not a canonical form: the package refuses it at the option before writing a byte, and a verifier meeting one refuses the record rather than guessing a digest. -2. **`"nodes"` is an array sorted by `"id"`** — by the bytes of the UTF-8 id, which is the +2. **`"nodes"` is an array sorted by `"id"`**: by the bytes of the UTF-8 id, which is the same as by code point. Each node is an object with exactly `"id"`, `"kind"`, `"labels"`, - `"level"` — in that order, which is their sorted order. + `"level"`, in that order, which is their sorted order. 3. **`"edges"` is an array sorted by the tuple** (`from`, `to`, `kind`, `provenance`), each compared as in rule 2, left to right. Each edge is an object with exactly `"from"`, - `"kind"`, `"provenance"`, `"sign"`, `"to"` — in that order, which is their sorted order. + `"kind"`, `"provenance"`, `"sign"`, `"to"`, in that order, which is their sorted order. **No weight.** That tuple is an edge's identity: `BeamMCP.Connectome.Graph.new/1` refuses two edges with the same tuple and an edge whose `from` or `to` names no node, so the canonical form never meets either; it neither merges nor drops. The sign is not part of - the identity — one edge carries one sign. A graph struct built by literal, bypassing + the identity: one edge carries one sign. A graph struct built by literal, bypassing `BeamMCP.Connectome.Graph.new/1`, is read against the same refusals before a byte is written (`BeamMCP.Connectome.Graph.check/1`) and refused as `{:invalid_graph, reason}` under the graph's own name for the fault. -4. **Every other object** — `"labels"` and anything nested in it — **sorts its keys by UTF-16 +4. **Every other object** (`"labels"` and anything nested in it) **sorts its keys by UTF-16 code unit**, the order [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (the JSON Canonicalization Scheme) uses. For keys within the Basic Multilingual Plane this is code point order; a key containing a character above U+FFFF sorts by its surrogate pair, so @@ -57,13 +57,13 @@ The bytes are UTF-8 JSON with no insignificant whitespace, written under these r arrive as an atom or a string and is written as the same string either way; the three atoms `nil`, `true` and `false` are the JSON literals `null`, `true` and `false` as values, and are refused as keys. -6. **Strings are NFC-normalised** before anything else — ids, edge endpoints, label keys and +6. **Strings are NFC-normalised** before anything else: ids, edge endpoints, label keys and label values, whether they arrived as strings or as atoms. NFC, not NFKC: canonical equivalents fold (a combining sequence and its precomposed form, U+212B and U+00C5), and compatibility equivalents stay distinct (`fi` U+FB01 and `fi` are two strings). Two nodes whose ids coincide after normalisation are refused, never merged; so are two label keys in one object that coincide after normalisation, or an atom and a string spelling one key. - **A string that is not well-formed UTF-8 is refused** — well-formed as Unicode defines it: + **A string that is not well-formed UTF-8 is refused**, well-formed as Unicode defines it: no overlong form, no encoded surrogate, nothing past U+10FFFF, no truncated sequence; noncharacters such as U+FFFE and a byte-order mark are well-formed and written literally. The refusal names the node and the field for an id or a top-level label; inside a nested @@ -82,10 +82,10 @@ The bytes are UTF-8 JSON with no insignificant whitespace, written under these r objects of these (sorted by rule 4). An atom label value is written as a string. Integers are decimal with no sign for zero or positive values, a leading `-` for negative ones, no leading zeros, no exponent, and no upper bound: an integer beyond 2^53 is written in full, - and a reader that cannot hold it exactly cannot re-derive the bytes — that is the reader's + and a reader that cannot hold it exactly cannot re-derive the bytes; that is the reader's limit, stated here rather than rounded. An empty object is `{}` and an empty array `[]`. **A float is refused**, because two runtimes may print it differently; so is anything with - no byte form (a reference, a pid, a tuple, a function), and a struct — a struct is not a + no byte form (a reference, a pid, a tuple, a function), and a struct: a struct is not a labels map, and its fields are the private layout of another module, which a release may change. A refusal at any depth is reported by node, by the label it was met under, and by that label's whole value. @@ -95,7 +95,7 @@ The bytes are UTF-8 JSON with no insignificant whitespace, written under these r (64 bytes; one hundred and twenty-eight). The algorithm member is part of the bytes, so it is under the hash: two envelopes of the same graph that name different digests are different bytes with different hashes, and neither is a rewrite of the other. A verifier - reads the version first, then the algorithm, then hashes — it never chooses a digest the + reads the version first, then the algorithm, then hashes; it never chooses a digest the bytes do not name, and it treats a name it does not know as a malformed record. SHA-256 is the default the package writes when the caller names none, and stays the default indefinitely; the other two are a caller's option (`algorithm:` on @@ -116,7 +116,7 @@ sha256: `a401cd47f0f0410d17248538eb8a3ef00018f6a31bf6fb6a7b9b0ba3377f570e` Reproduce it without the package: paste the line above into a file with no trailing newline and run `sha256sum` over it, or `printf '%s' '' | sha256sum`. -The same graph under SHA-384 is a different envelope — one member differs — and so a +The same graph under SHA-384 is a different envelope (one member differs) and so a different hash, over the bytes that name it: ```json-canonical-sha384 @@ -138,7 +138,7 @@ byte but that value. `schema_version` names the vocabulary the bytes were written in and, from `3`, the shape of the envelope. **`1`** (0.4.0): the sign values were `allow`, `deny`, `hold`, `unknown`. -**`2`** (0.5.0): `unset` — no sign was supplied to the package that wrote the bytes — and +**`2`** (0.5.0): `unset` (no sign was supplied to the package that wrote the bytes) and `ungoverned`, a consumer's affirmative "no rule of my policy applies", replace `unknown`; nothing else moved. **`3`** (`0.6.0`): the envelope names its algorithm as a fourth top-level member, `"algorithm"`, between the version and the @@ -148,7 +148,7 @@ at an edge and found nothing governing it had no `ungoverned` to write, so its h was `unknown` too, and the bytes do not say which case a given `unknown` was. A reader must not narrow a version-1 `unknown` to `unset`; it is "one of the two, unrecorded which". -**What a verifier holding bytes at `1` or `2` does** — 0.4.0 and 0.5.0 bytes exist in the +**What a verifier holding bytes at `1` or `2` does**: 0.4.0 and 0.5.0 bytes exist in the world, and their hashes stay verifiable forever under their own version: those bytes name no algorithm, and **at `1` and `2` the digest is SHA-256**, by this rule and by no member of the bytes; the verifier hashes the bytes it holds, unchanged, with SHA-256, and compares. It does @@ -165,7 +165,7 @@ What the version tells the verifier beyond the digest is how to *read* the sign `ungoverned` are valid and `unknown` is not. A reader that resolves the vocabulary by the version it finds first (as Avro resolves a writer's schema against a reader's) needs no other signal. A sign outside the vocabulary of the version the bytes name is a malformed record: -the hash still verifies (it is over the bytes), and the record is refused, never corrected — +the hash still verifies (it is over the bytes), and the record is refused, never corrected: the rule the package applies to itself in `BeamMCP.Connectome.Graph.new/1`. The package itself writes `3` and only `3`, and `BeamMCP.Connectome.Graph.new/1` refuses a graph carrying any other version rather than translating it: bytes are not re-imported here, only produced and @@ -174,23 +174,23 @@ kept as they were, and a test verifies them the way this section says. ## The sidecar -Weights are written separately as `{"schema_version":3,"weights":[…]}` — the graph's version, no algorithm member, since the sidecar is never hashed —, one object per edge +Weights are written separately as `{"schema_version":3,"weights":[…]}` (the graph's version, no algorithm member, since the sidecar is never hashed), one object per edge that carries a weight, each with `"from"`, `"kind"`, `"provenance"`, `"to"`, `"weight"` in that order, the array sorted as in rule 3, the same string rules. An integer weight is an integer; a float weight is written in the shortest form that round-trips -(`:erlang.float_to_binary/2` with `:short`, OTP 25's — every release this package compiles on +(`:erlang.float_to_binary/2` with `:short`, OTP 25's; every release this package compiles on has it, the floor being OTP 27, and the placement below is measured on OTP 27, 28 and 29 by the CI matrix). The digits are the shortest that round-trip; the placement is Erlang's, which differs from other runtimes' shortest forms and is decided by the digit count of the mantissa, not by the magnitude. Write the digits as D, a string with no trailing zeros, of length L, and let e be the power of ten such that the value is D × 10^e. Plain notation is used when −4 ≤ e ≤ 2 for a one-digit D, and when -−(L+2) ≤ e ≤ 1 otherwise (≤ 2 when e + L − 1 ≥ 10) — except that D ≥ 2⁵³ with e = 0, +−(L+2) ≤ e ≤ 1 otherwise (≤ 2 when e + L − 1 ≥ 10), except that D ≥ 2⁵³ with e = 0, D > 2⁵² div 5 with e = 1, and D > 2⁵¹ div 25 with e = 2 take an exponent. Plain notation appends `.0` when no digit falls after the point, and prefixes `0.` and −(L+e) zeros when L + e ≤ 0. Exponent notation is the first digit, `.`, the remaining digits or `0`, `e`, and -e + L − 1 with no `+` and no padding. Zero is `0.0`, and a negative zero — which the edge -constructor admits, as it is not below zero — is `-0.0`. So `0.1`, `0.0001`, +e + L − 1 with no `+` and no padding. Zero is `0.0`, and a negative zero (which the edge +constructor admits, as it is not below zero) is `-0.0`. So `0.1`, `0.0001`, `999999999999999.0`, `1.0e15`, `1.0e-5`, `1.0e20`, `9.99e14`, `3.0e6`, `1.23456e-4`, `0.001234`, `12340.0`, `0.0`, `-0.0`. The sidecar is not part of any hash. It is defined only for a graph whose declared form encodes: what `encode/1` refuses, `sidecar/1` refuses @@ -231,26 +231,26 @@ provenance and sign, and no weight. They are not canonical forms and are not has DOT quotes every id and every value, and quotes a label's attribute name too (`"label_"`), because a key may carry `=`, a space or a quote; `kind`, `level` and the edge attributes are DOT identifiers and stay bare. GraphML's schema types every id as an NMTOKEN; a node id is -not one (it carries `/`), and the export writes ids as given and renames nothing — a reader +not one (it carries `/`), and the export writes ids as given and renames nothing; a reader that validates against the schema would reject them. Label keys are declared as `l0`, `l1`, … in the order of rule 4 with `attr.name` carrying the key, so a key carrying a space or a quote never lands in an id. Text is escaped: `&`, `<`, `>`, `"`, and -tab, LF and CR as the character references ` `, ` `, ` ` — a parser folds the +tab, LF and CR as the character references ` `, ` `, ` `: a parser folds the literal characters to a space inside an attribute value and CR to LF in content, and a reference survives both, so two ids that differ only by whitespace kind stay two nodes. A -character outside XML 1.0's Char production — `#x9 | #xA | #xD | [#x20-#xD7FF] | +character outside XML 1.0's Char production (`#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]`, so a C0 control other than tab, LF and CR, or U+FFFE -or U+FFFF — which the canonical bytes do carry, is refused by `to_graphml/1` as +or U+FFFF), which the canonical bytes do carry, is refused by `to_graphml/1` as `{:not_xml, id, codepoint}` rather than written into a document every conforming parser rejects; no character reference can carry it either. There is no Cypher export, on purpose: the canonical bytes load into Neo4j as they are, with APOC's JSON loader over the `nodes` array (`MERGE` on `id`) and the `edges` array (`MATCH` -the two ids, `MERGE` the relationship) — two statements the Livebook shows — and a fourth +the two ids, `MERGE` the relationship), two statements the Livebook shows, and a fourth rendering would be one more surface no hash covers. The exports' exact bytes are not specified by this page. A label value that is not a string is written as its rule-8 JSON text in both, so the string `"true"` and the boolean `true` -read the same there; everything else — headers, indentation, attribute order, DOT's own -escape of `\` and `"` — is the exporter's layout, which `test/fixtures/connectome/golden.dot` +read the same there; everything else (headers, indentation, attribute order, DOT's own +escape of `\` and `"`) is the exporter's layout, which `test/fixtures/connectome/golden.dot` and `golden.graphml` pin and a release may change without the hash changing. diff --git a/docs/connectome-diff.md b/docs/connectome-diff.md index 30b0e631..9f65515d 100644 --- a/docs/connectome-diff.md +++ b/docs/connectome-diff.md @@ -5,8 +5,8 @@ SPDX-License-Identifier: Apache-2.0 # The diff: declared against observed -`BeamMCP.Connectome.Diff.run/3` takes the declared connectome and the observed one — two -`BeamMCP.Connectome.Graph` values — and a window the consumer supplies, and returns a +`BeamMCP.Connectome.Diff.run/3` takes the declared connectome and the observed one (two +`BeamMCP.Connectome.Graph` values) and a window the consumer supplies, and returns a record: every edge of either graph in exactly one of four classes, and a coverage bound as counts. The record has canonical bytes, written by the same rules as a node's labels, so a consumer signs it with the verifier it already has. This page is the contract a consumer can @@ -15,19 +15,19 @@ implement without importing the package; the worked example at the end reproduce ## Labels, not isomorphism -Two edges are the same edge if and only if their **label** — `from`, `to`, `kind` — is +Two edges are the same edge if and only if their **label** (`from`, `to`, `kind`) is equal, compared as the encoder writes them: **after NFC** (rule 6 of `docs/connectome-canonical.md`). Ids are structural, derived by one function from the identity a host supplies (`docs/connectome.md`, the id scheme; `srv/server` for the server node, `srv/tool/a` for a tool), so the label is the whole identity of an edge and provenance says only which graph it came from. Two inputs are admitted only as the encoder would admit -them: a graph whose ids coincide after NFC has no canonical bytes and so no diff — refused +them: a graph whose ids coincide after NFC has no canonical bytes and so no diff (refused as `{:error, {:declared, {:uncanonical, {:duplicate_id_after_nfc, id}}}}` or the observed -mirror — and a graph carrying an edge of the other side's provenance is refused as `{:error, +mirror), and a graph carrying an edge of the other side's provenance is refused as `{:error, {:declared, {:invalid, :provenance, :observed}}}` or the mirror, so on every admitted input an edge and a label are the same count. The diff is a set difference over labels and nothing more, and that is a decision, not an omission: a graph-isomorphism check is NP-hard in -general and would be wrong here besides — it would call two differently named tools "the +general and would be wrong here besides; it would call two differently named tools "the same" whenever their neighbourhoods matched. It is written down so nobody improves the diff into one later. @@ -35,17 +35,17 @@ into one later. | class | an edge whose label is | in the vocabulary | | -- | -- | -- | -| `declared_and_observed` | in both graphs, and the signs are not both supplied and different | — | +| `declared_and_observed` | in both graphs, and the signs are not both supplied and different | none | | `declared_never_observed` | in the declared graph only | **dead authority** | | `observed_but_undeclared` | in the observed graph only | a **drift finding** | -| `changed_sign` | in both, with a sign the consumer supplied on **both** sides — neither `unset` — and the two differ: two authorities disagree | a **drift finding** | +| `changed_sign` | in both, with a sign the consumer supplied on **both** sides (neither `unset`) and the two differ: two authorities disagree | a **drift finding** | A sign on one side only is not a change: `unset` is abstention, not a verdict, and the package never guesses what the missing one would have been, so `allow` against `unset` is -`declared_and_observed` — a sign was acquired, not changed. `ungoverned` is a supplied value, +`declared_and_observed`: a sign was acquired, not changed. `ungoverned` is a supplied value, so `ungoverned` against `deny` *is* changed-sign, and `unset` against `ungoverned` is not. The standalone case, `unset` on both sides, never produces a finding. Every label of either input -lands in exactly one class; the four classes partition the union of the labels — and the sign +lands in exactly one class; the four classes partition the union of the labels, and the sign never moves a label between classes: an observed edge nobody declared is drift whatever its sign, and a declared `ungoverned` edge is recorded like any other. @@ -58,33 +58,33 @@ consumer's to divide: | -- | -- | | `declared_edges` | edges in the declared graph | | `observed_edges` | edges in the observed graph | -| `declared_and_observed` | labels in both — the two "in both" classes together | +| `declared_and_observed` | labels in both: the two "in both" classes together | | `declared_endpoint_covered` | declared edges whose `from` **and** `to` are both ids of observed nodes | | `observed_endpoint_declared` | observed edges whose `from` **and** `to` are both ids of declared nodes | | `declared_nodes` | nodes in the declared graph | | `observed_nodes` | nodes in the observed graph | | `nodes_in_both` | node ids in both | -| `declared_sign_only` | labels in both with a sign supplied on the declared side and `unset` on the observed — signed at configuration time, unsigned in the run (the odder direction, so it is its own count) | -| `observed_sign_only` | labels in both with a sign supplied on the observed side and `unset` on the declared — an authority spoke during the run about an edge nobody had signed at configuration time | +| `declared_sign_only` | labels in both with a sign supplied on the declared side and `unset` on the observed: signed at configuration time, unsigned in the run (the odder direction, so it is its own count) | +| `observed_sign_only` | labels in both with a sign supplied on the observed side and `unset` on the declared: an authority spoke during the run about an edge nobody had signed at configuration time | -The coverage bound `docs/connectome.md` defines — "the measured fraction of one graph the -other accounts for, stated with the window" — is made of these figures, and they are +The coverage bound `docs/connectome.md` defines ("the measured fraction of one graph the +other accounts for, stated with the window") is made of these figures, and they are different figures, not one: -- **declared edges observed** — `declared_and_observed / declared_edges`. -- **observed edges declared** — `declared_and_observed / observed_edges`. -- **completeness** — `observed_endpoint_declared / observed_edges`. This is the +- **declared edges observed**: `declared_and_observed / declared_edges`. +- **observed edges declared**: `declared_and_observed / observed_edges`. +- **completeness**: `observed_endpoint_declared / observed_edges`. This is the connectomics figure, "synaptic completeness: the fraction of synapses between fully proofread cells", with its roles kept: the population is what the measurement found (every observed edge, declared or not, as every detected synapse is counted whether or not a curated cell claims it) and the condition is membership of both ends in the curated - set — here, the declaration. "Proofread cell" maps to "declared node"; "detected synapse" + set: here, the declaration. "Proofread cell" maps to "declared node"; "detected synapse" to "observed edge". An observed edge nobody declared still counts when it ran between - declared parts; an edge that ran wholly outside the declared parts counts against it — + declared parts; an edge that ran wholly outside the declared parts counts against it, and no other figure tells those two apart: both are `observed_but_undeclared`, and both lower "observed edges declared" the same. -- **endpoint coverage** — `declared_endpoint_covered / declared_edges`: the dual, with the - roles swapped — how much of the declaration sits where the window reached at all. An +- **endpoint coverage**: `declared_endpoint_covered / declared_edges`, the dual, with the + roles swapped: how much of the declaration sits where the window reached at all. An observed node here is one that appeared as an endpoint of at least one observed edge in the window, nothing more; an edge counted under `declared_endpoint_covered` may still be dead authority. A label in both graphs has both ends in both, so @@ -94,59 +94,59 @@ different figures, not one: (The first draft of this page called the dual "completeness"; a review lane implementing the definitions from the page found the roles swapped. Elsewhere in the package, -`BeamMCP.Connectome.Declared.Bound` is "the completeness bound" of the *declared* build — an -enumeration of what static analysis could not see — a different thing from this fraction.) +`BeamMCP.Connectome.Declared.Bound` is "the completeness bound" of the *declared* build (an +enumeration of what static analysis could not see), a different thing from this fraction.) -The **window** is the consumer's: any map in the label grammar — string or atom keys (an +The **window** is the consumer's, any map in the label grammar: string or atom keys (an atom is written as its name), nested maps, lists, integers, booleans, `null`; an empty map -is allowed and written `{}` — carried into the record verbatim, and so is its size: the -record is at least as large as the window, and the window is encoded twice — once at +is allowed and written `{}`. It is carried into the record verbatim, and so is its size: the +record is at least as large as the window, and the window is encoded twice: once at `run/3`, to refuse one with no canonical bytes early, and once at `encode/1` (measured: a 1 MB window costs ~55 ms at each). Nothing in the package bounds it; a consumer that signs records bounds its own windows. The package never infers it; a diff without a window is -refused by name (`{:error, {:missing, :window}}`), and a window with no canonical bytes — a -float inside, a struct, a keyword list, two keys that coincide after NFC — as `{:error, +refused by name (`{:error, {:missing, :window}}`), and a window with no canonical bytes (a +float inside, a struct, a keyword list, two keys that coincide after NFC) as `{:error, {:uncanonical, {:label_value, "record", :window, value}}}`. The options themselves must be a keyword list carrying `window:` and nothing else (`{:error, {:invalid, :opts, given}}` for another shape, `{:error, {:invalid, :opts, [key, ...]}}` for a key the function does not -take — a typo is not "no window"). Refusals are answered in a fixed order: the options, then -the declared graph, then the observed graph, then the window — the first refusal found is +take; a typo is not "no window"). Refusals are answered in a fixed order: the options, then +the declared graph, then the observed graph, then the window; the first refusal found is the one returned. A graph a literal built wrong is refused before it is compared, as `{:error, {:declared, reason}}` or `{:error, {:observed, reason}}` with the reason `BeamMCP.Connectome.Graph.new/1` would have given. ## The bytes -The record is one object written by rule 4 of `docs/connectome-canonical.md` — the rules of +The record is one object written by rule 4 of `docs/connectome-canonical.md`, the rules of a node's `"labels"` object, applied to the whole record (`BeamMCP.Connectome.Canonical.encode_value/1`): keys in UTF-16 code-unit order and unique after NFC, strings NFC, every atom a string under the name of its field, integers as integers, arrays in the order given, nested objects the same way; nothing else. Its keys, in the order the rule gives them: -- `"algorithm"` — the digest the record is hashed with, `"sha256"` unless the caller chose +- `"algorithm"`: the digest the record is hashed with, `"sha256"` unless the caller chose `"sha384"` or `"sha512"` (`algorithm:` on `BeamMCP.Connectome.Diff.encode/2` and `hash/2`; rule 9 of `docs/connectome-canonical.md`, the same three names). The keys sort, so it is the first member a reader meets; it is under the hash like every other. -- `"classes"` — an object with the four class names as keys, each an array of label objects +- `"classes"`: an object with the four class names as keys, each an array of label objects `{"from","kind","to"}` (an empty class is `[]`); a `changed_sign` entry carries `"declared_sign"` and `"observed_sign"` too. Each array is sorted by `from`, then `to`, then the kind's name, comparing UTF-16 code units, so equal inputs give equal bytes whatever order the graphs were built in. -- `"coverage"` — the ten counts (eight since 0.4.0; the two one-sided sign counts since 0.5.0). -- `"schema_version"` — `3` (the record's own axis: bumped to `2` in 0.5.0 when changed-sign +- `"coverage"`: the ten counts (eight since 0.4.0; the two one-sided sign counts since 0.5.0). +- `"schema_version"`: `3` (the record's own axis: bumped to `2` in 0.5.0 when changed-sign excluded `unset` by name and two counts were added, and to `3` in 0.6.0 when the algorithm joined the bytes; `1` and `2` records carry the earlier semantics, name no - algorithm, and are SHA-256 over their bytes — a verifier reads the version first and + algorithm, and are SHA-256 over their bytes; a verifier reads the version first and hashes `1` and `2` records with SHA-256 exactly as before, as the canonical page's *Versions* section says for the graph). -- `"window"` — the consumer's map. +- `"window"`: the consumer's map. Nothing else enters the record: no weight, no latency, no argument, no label, no name the -graphs did not already carry — and every name they do carry is in it: a tool's, a module's, +graphs did not already carry, and every name they do carry is in it: a tool's, a module's, a registered process's name is identity and is published, as `docs/connectome-observed.md` says; a secret in a name is published here too. `hash/1` encodes and hashes; a consumer that wants both the bytes and the hash hashes the bytes it already holds rather than paying the -encode twice. `BeamMCP.Connectome.Diff.hash/2` is the digest the bytes name over these bytes — the raw +encode twice. `BeamMCP.Connectome.Diff.hash/2` is the digest the bytes name over these bytes: the raw 32, 48 or 64; `BeamMCP.Connectome.Diff.hash_hex/2` is the same as lowercase hexadecimal, the form this page writes it in. @@ -170,6 +170,6 @@ because its observed sign is `unset`, not supplied. ## What the diff does not do It does not sign (the consumer's, through the seam a later slice names). It does not decide -what a finding means — drift is a record; what to do about it is the host's. It carries no +what a finding means: drift is a record; what to do about it is the host's. It carries no motif or rich-club figure and no re-approval state. It reads no weight: a weight is a measurement in the observed sidecar, and the diff is about identity. diff --git a/docs/connectome-observed.md b/docs/connectome-observed.md index 38d992ce..a8ab2862 100644 --- a/docs/connectome-observed.md +++ b/docs/connectome-observed.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# The connectome — the observed side +# The connectome: the observed side The declared connectome is built from the tree. The observed one is built from what ran: the dispatch path emits, a collector the host starts turns emissions into edges, and a @@ -28,9 +28,9 @@ writes.** A call the schema refuses is not a dispatch and emits nothing. `:excep `span/3`'s shape with one difference: the BEAM puts a call's argument list in the top frame of a `function_clause` or a BIF error's stacktrace, so the frames in the event carry the arity in that position and never the list, and a frame's location keeps file and line -only, and only as the compiler writes them — a charlist and an integer — a host can put +only, and only as the compiler writes them (a charlist and an integer). A host can put any term into a frame through `:erlang.error/3`'s `error_info` or hand `:erlang.raise/3` -frames of any shape, and none of it travels or breaks the rewrite — a frame whose arity +frames of any shape, and none of it travels or breaks the rewrite: a frame whose arity position is neither a list nor an integer is dropped from the event, as a fun frame is; the host's own stacktrace is re-raised untouched. The rewrite has a name, `BeamMCP.Stacktrace.arities/1`, and one implementation: the HTTP transport's fault log @@ -40,13 +40,13 @@ ships that path. `reason` is the exception the host's dispatch raised, whatever the host put in it (a `KeyError` can carry the map it was asked, for one); that is the host's, and the collector never reads it. The names are identity and do enter: `server_name`, the tool's name, and through the tracer a module's name and a registered process's name go verbatim into the -bytes a consumer signs — a secret in a name is published. +bytes a consumer signs: a secret in a name is published. ## The collector `BeamMCP.Connectome.Observed` is a process the host adds to its own supervision tree and names; the package starts nothing. Running, it owns one ETS set and one handler on `:stop` -and `:exception` — a call that returned and a call that raised are both an attempt, and the +and `:exception`: a call that returned and a call that raised are both an attempt, and the edge is the attempt. Each attempt is one row keyed by the edge's identity (the server node, the tool node, `:invoke`), holding a count and a latency summary; a repeated call is the same row with the count incremented, so the table is bounded by the number of distinct @@ -56,7 +56,7 @@ the hot path. Per-call cost is a number in the release notes, not a claim here. `snapshot/1` is the graph: one node per server and tool seen, one edge per row with the count as its weight, every sign `:unset`, built through the same constructors as the -declared side, on the same ids — the two graphs join on ids and on edge keys. Observed +declared side, on the same ids: the two graphs join on ids and on edge keys. Observed nodes carry no labels: the collector saw a call, not a catalog entry, so a declared tool node and its observed counterpart differ in `labels` while sharing an id. When no collector runs under the name it is a **named refusal**, `{:error, :not_started}`, and not an empty graph: an empty @@ -64,12 +64,12 @@ observed connectome says "nothing ran", which is a different claim from "nothing watching", and a diff that took the first for the second would report every declared edge as dead authority. A running collector that has seen no calls is an empty graph. -`latency/1` is a summary per edge — count, mean and maximum in microseconds — and never +`latency/1` is a summary per edge (count, mean and maximum in microseconds) and never the samples. It is not part of any hash and not part of the canonical sidecar. The durations are the host's dispatch function's own timing, as the counts are its own calls. -The table is public and `observe/5` is a host's to call. A row of another shape — an -identity the builders would not derive, a count below one — is refused by `snapshot/1` and +The table is public and `observe/5` is a host's to call. A row of another shape (an +identity the builders would not derive, a count below one) is refused by `snapshot/1` and `latency/1` as `{:error, {:malformed_row, key}}`, never built into a graph. **The table dies with its process.** A restart under the host's supervisor starts from no @@ -86,40 +86,40 @@ running trace as `{:shutdown, :companion_gone}`. `BeamMCP.Connectome.Tracer` sees the edges telemetry cannot: a module calling a module, a process sending to a process. It is off until a host starts it, runs one at a time, and refuses to start without a running collector, with a limit that is not a positive integer -— there is no unbounded mode — with the wildcard or an unloadable module in `modules:`, or +(there is no unbounded mode), with the wildcard or an unloadable module in `modules:`, or with a name in `processes:` that is not registered; a start that fails part-way leaves -nothing set and answers `{:error, {:init_failed, reason}}` — a name in `processes:` +nothing set and answers `{:error, {:init_failed, reason}}`: a name in `processes:` registered to a port rather than a process, or whose holder exited between the check and the start, is the cause that remains. **One trace session, the tracer's own.** Everything the tracer sets it sets inside one -OTP trace session (`:trace.session_create/3`, OTP 27 — the reason this package's floor is +OTP trace session (`:trace.session_create/3`, OTP 27, the reason this package's floor is 27) whose tracer is the tracer process: the call patterns on the named modules, the call flag on every process in the node, present and future, and the send flag on the named processes. Sessions are isolated from each other and from the legacy `:erlang.trace/3` session a host may be using. So a process a host already traces is traced by this -session as well and its calls are edges (measured: both tracers received the call — under +session as well and its calls are edges (measured: both tracers received the call; under the legacy tracer the BEAM skipped such a process silently, one tracer per process, and its calls were no edges); a host's own pattern on a module the tracer names is neither fed by the tracer's pattern nor touched by its clear (measured: the host's call tracer saw only what its own pattern generated, and its pattern read `local` after the tracer's session was gone); and nothing here reads or clears a flag or a pattern that is not the -session's — a host's flags on any process, its patterns on any module, its own sessions, +session's: a host's flags on any process, its patterns on any module, its own sessions, all survive the tracer's start, stop, deadline, limit and kill alike. The legacy view, `:erlang.trace_info/2`, does not see a session's settings at all: a host reading it will find none of the tracer's, which is the isolation working, not a tracer that set nothing; the tests that pin this page read `:trace.session_info/1`. It stops itself at `max_messages` handled trace messages of any shape, a send to a dead -process included, or at `max_duration_ms`, destroying its session — every pattern and -flag it set, in one call — and exits `{:shutdown, {:limit, which, value}}`; `stop/0` is +process included, or at `max_duration_ms`, destroying its session (every pattern and +flag it set, in one call) and exits `{:shutdown, {:limit, which, value}}`; `stop/0` is the third way out; the message limit ends the tracer on the message that reaches it, the deadline and `stop/0` on the next message it handles, and what was still queued behind it is discarded, not written; the collector dying under it is the fourth, `{:shutdown, :collector_gone}`. **What the limits bound:** `max_messages` counts messages as they are handled while the BEAM queues them as they arrive, so the tracer runs at high priority and destroys its session the moment handled plus queued -reaches the limit — generation stops there. The queue's size is the node-wide call rate +reaches the limit; generation stops there. The queue's size is the node-wide call rate into the named modules times the tracer's scheduling latency, not `max_messages` (measured: 64 hot callers against a limit of 1 000 peaked from some tens of thousands to a hundred-odd thousand queued messages, run to run; with a limit too large to reach, 32 @@ -129,16 +129,16 @@ carries the sent term: a traced process's sends are copied into the tracer's mai their own size until they are handled and dropped unread (measured: one traced send of a million-element list put 16 MB on the tracer), so that bound is the traced processes' own message sizes; nothing of a term is written, and anything that can read the tracer's -queue can see one while it waits. The early clear is best effort — the queue is read on -the first and every 32nd message — while the hard bounds hold on every path: at most +queue can see one while it waits. The early clear is best effort (the queue is read on +the first and every 32nd message) while the hard bounds hold on every path: at most `max_messages` handled, the session destroyed at the limit and on every exit. A companion -process enforces `max_duration_ms` from outside the tracer's mailbox — raising a flag at +process enforces `max_duration_ms` from outside the tracer's mailbox, raising a flag at the deadline and then destroying the session; the tracer reads the flag before every write, so nothing lands after it is raised. The tracer watches the companion back and exits `{:shutdown, :companion_gone}` if it dies. **Nothing left behind, on every path.** The session's handle is held by the tracer and by -its companion and by nothing else the tracer writes — never in a persistent term, which +its companion and by nothing else the tracer writes, never in a persistent term, which would keep a dead tracer's session, and its breakpoints, alive until erased (measured); a copy anywhere is a holder too, and `:sys.get_state/1` on the tracer makes one on the caller's heap that holds the session up until that process next collects (measured: 50 ms @@ -146,13 +146,13 @@ after both holders had died, the session was still listed). `stop/0`, the limit, the deadline and `terminate/2` each destroy the session by name; and a session whose every handle is gone is destroyed by the BEAM itself (measured: the last holder killed, the pattern was gone within 20 ms). The companion monitors the tracer and exits -on its exit — a kill included, which skips `terminate/2` — and that exit is the clear: the +on its exit (a kill included, which skips `terminate/2`), and that exit is the clear: the last holder gone, the session goes with it. The window the legacy tracer left open, the companion killed and then the tracer killed before it handles that death, closes the same way: both holders gone, the session with them, whatever modules it named; there is no running term to clear, no stale term for a next `start/1` to read, and no wait for a previous companion. A session whose tracer has died but whose handle is still held keeps -its patterns set — the BEAM drops a dead tracer's process flags, not its patterns — at a +its patterns set (the BEAM drops a dead tracer's process flags, not its patterns) at a cost per call into those modules (measured: 200 000 calls, from the baseline's order to 2.4 times it, run to run): a companion suspended from outside is such a holder, and `terminate/2`'s own destroy is what ends the session on an orderly exit while it is; a @@ -160,25 +160,25 @@ start that fails part-way destroys its session before the reason leaves `init/1` the error term carries the raise's arguments, the handle among them; a companion that outlives its tracer that way destroys its own session and no other, since a handle reaches one session and a later tracer's is another. A running tracer's `stop/0` -reads the tracer's own claim — the flag and the session handle — from the tracer's +reads the tracer's own claim (the flag and the session handle) from the tracer's process dictionary, which nothing outside it can write, raises the flag and destroys the session before asking the tracer to stop, and waits a bounded five seconds for it to -leave, `:ok` either way; a claim of another shape — a process that took the tracer's name -and put one there — is no claim, and a term in it that is no handle destroys nothing. +leave, `:ok` either way; a claim of another shape (a process that took the tracer's name +and put one there) is no claim, and a term in it that is no handle destroys nothing. `stop/0` with no tracer running is `:ok` and touches no session. The collector dying under the tracer is met as its DOWN or as the first write into the table that is gone, whichever is first in the queue, and is `{:shutdown, :collector_gone}` either way. The tracer is started unlinked and traps exits, so no process's exit signal short of an -untrappable `:kill` ends it — the starter's included — each is a message it ignores, not +untrappable `:kill` ends it (the starter's included); each is a message it ignores, not a stop: tracing goes on to its limits, and `stop/0` is the way to end it from outside. It never calls `:dbg`. **The threat model, which is the boundary of every claim in this section.** In scope: -accident and failure on a node running only code the host put there — crashes, kills, +accident and failure on a node running only code the host put there: crashes, kills, restarts and the host's supervisor, a registered name reused by an unrelated process, a host tracing its own processes under the legacy tracer or under a session of its own, hot reload, starvation under load, and the public API called wrongly or in the wrong order. -Out of scope: an adversary executing code inside the same BEAM node — a process that +Out of scope: an adversary executing code inside the same BEAM node: a process that registers itself under the tracer's name, a crafted process dictionary, a handle taken from the tracer's dictionary and destroyed. Such an adversary can already read the collector's ETS table directly, call the host's dispatch function, replace a module with @@ -196,8 +196,8 @@ unregistered process dropped rather than written under a pid. Three bounds, measured: a call in tail position has no frame of its own, so the BEAM names the caller's caller; names are resolved when a trace message is handled, so a process that -exited or unregistered in between is dropped; and OTP's own registered processes — the -code server, a logger handler, telemetry's table owner — are names like any other, so a +exited or unregistered in between is dropped; and OTP's own registered processes (the +code server, a logger handler, telemetry's table owner) are names like any other, so a traced process's sends to them are edges too; the one registered process a send to which is no edge is the tracer itself (`stop/0`, a `:sys` call). The tracer never traces its own writes: the BEAM discards an event whose tracer is the process that generated it. diff --git a/docs/connectome-reach.md b/docs/connectome-reach.md index 36403c1f..9525564d 100644 --- a/docs/connectome-reach.md +++ b/docs/connectome-reach.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Reachability: what an entry can reach, and what it must cross to get there -`BeamMCP.Connectome.Reach` answers four questions about a `BeamMCP.Connectome.Graph` — the +`BeamMCP.Connectome.Reach` answers four questions about a `BeamMCP.Connectome.Graph`; the declared graph is the point, since it says what *can* happen. This page is the contract: the definitions, what an answer carries, what each question costs, and what is refused. A consumer can check every answer against the graph it handed in, because the graph is the @@ -16,13 +16,13 @@ only input and a witness is made of the graph's own edges. | function | question | answer | | -- | -- | -- | | `BeamMCP.Connectome.Reach.reachable?/4` | is there a path from `from` to `to`? | `{:ok, true \| false}` | -| `BeamMCP.Connectome.Reach.reachable_without/5` | is there a path from `from` to `to` that crosses none of the `gates`? | `{:ok, false}`, or `{:ok, %Path{}}` — the witness | +| `BeamMCP.Connectome.Reach.reachable_without/5` | is there a path from `from` to `to` that crosses none of the `gates`? | `{:ok, false}`, or `{:ok, %Path{}}`: the witness | | `BeamMCP.Connectome.Reach.dominates?/4` | does every path from the entry set to `target` pass `gate`? | `{:ok, true \| false}` | -| `BeamMCP.Connectome.Reach.mandatory_pass/3` | which nodes does every path from the entry set to `target` cross? | `{:ok, MapSet}` — the dominators of `target`, itself excluded | +| `BeamMCP.Connectome.Reach.mandatory_pass/3` | which nodes does every path from the entry set to `target` cross? | `{:ok, MapSet}`: the dominators of `target`, itself excluded | A **path** is a sequence of the graph's edges, each edge's `to` the next edge's `from`, following edges in their direction. A path from a node to itself is the empty path, zero -hops — a node reaches itself whether or not a cycle passes through it. **Crossing** a node +hops: a node reaches itself whether or not a cycle passes through it. **Crossing** a node means the node is on the path, at either end or between: `reachable_without/5` answers `{:ok, false}` when `from` or `to` is itself a gate. @@ -36,29 +36,29 @@ server is always crossed". ## The witness `reachable_without/5` returns `%BeamMCP.Connectome.Reach.Path{nodes: [...], edges: [...]}`: -`nodes` from `from` to `to` in order, `edges` one input edge per step — the first edge in the +`nodes` from `from` to `to` in order, `edges` one input edge per step, the first edge in the graph's edge order (canonical, for a graph `BeamMCP.Connectome.Graph.new/1` built) between the -two nodes whose kind the query admitted — so +two nodes whose kind the query admitted, so `length(edges) == length(nodes) - 1`, every edge is `in graph.edges`, each consecutive pair of nodes is that edge's `from` and `to`, and no gate is among the nodes. A property test holds all four over generated graphs. The witness is a **shortest** gate-free path in hops among the admitted kinds; when several are shortest, the one breadth-first search finds first. -The witness is a path in the **input** graph at the level the graph was built at — a module +The witness is a path in the **input** graph at the level the graph was built at: a module graph's witness names modules, never a collapsed group. -## Constraints — each scoped to the questions it bears on +## Constraints, each scoped to the questions it bears on -- `kinds:` (every query) — the edge kinds the search may follow, a non-empty subset of the +- `kinds:` (every query): the edge kinds the search may follow, a non-empty subset of the vocabulary's four (`docs/connectome.md`); default all four. An edge of another kind is not there. -- `max_hops:` (`reachable?/4` and `reachable_without/5` only) — a positive integer or +- `max_hops:` (`reachable?/4` and `reachable_without/5` only): a positive integer or `:infinity` (default). A shortest path longer than it is not a path for the question asked: `reachable?/4` answers `false`, `reachable_without/5` answers `{:ok, false}`. `max_hops: 1` is adjacency. -- `entries:` (`dominates?/4` and `mandatory_pass/3` only) — the entry set; a non-empty list +- `entries:` (`dominates?/4` and `mandatory_pass/3` only): the entry set; a non-empty list of node ids the graph holds. Without it, the server nodes; a graph with none is refused (`{:invalid, :entries, []}`), never answered "unreachable". -- `max_edges:` (every query) — see below. +- `max_edges:` (every query): see below. **An option that cannot bear on the question asked is refused by name** (`{:error, {:unknown_option, key}}`), not read. A review found that with `max_hops:` read by @@ -81,23 +81,23 @@ other on every node of generated graphs: - `dominates?/4` is the definition itself: `target` is reachable from the root with `gate` present, and unreachable with `gate` and its edges removed. Two searches. - `mandatory_pass/3` is Lengauer–Tarjan (1979), the simple variant with path compression, - over the part of the graph the root reaches — one build; the algorithm's own depth-first - numbering is what says the target is unreachable — returning the chain of immediate + over the part of the graph the root reaches (one build; the algorithm's own depth-first + numbering is what says the target is unreachable), returning the chain of immediate dominators above `target` with the root and `target` left out. OTP's `:digraph_utils` has no dominator function (measured on OTP 28), so it is written in the package and held to - `dominates?/4` — by the property, and by two independent oracles a review lane wrote from + `dominates?/4`: by the property, and by two independent oracles a review lane wrote from this page's definition (all simple paths intersected; node removal), which agreed with both functions on every target and gate of 1 200 random graphs. When `target` is unreachable from the entry set both answer `{:error, {:unreachable, target}}`: dominance is undefined there, and `true` would make an unreachable effect look guarded. -`dominates?(g, x, x)` is `{:ok, true}` when `x` is reachable — every path to `x` passes `x`. +`dominates?(g, x, x)` is `{:ok, true}` when `x` is reachable: every path to `x` passes `x`. ## What it costs Every query checks the graph (`BeamMCP.Connectome.Graph.check/1`, O(V + E)) and builds a -private `:digraph` from it — O(V + E), the edges filtered to `kinds:` and the removed nodes -left out — and deletes it when the query returns, on every exit. On top of that: +private `:digraph` from it (O(V + E), the edges filtered to `kinds:` and the removed nodes +left out) and deletes it when the query returns, on every exit. On top of that: | function | work | bound | | -- | -- | -- | @@ -111,7 +111,7 @@ Measured on the gate's 10 000-edge fixture (501 nodes, ~9 970 edges after de-dup gate run and judged by no number): a search ~14–21 ms across this machine's runs, of which the graph check and the `:digraph` build are most; `dominates?/4` ~24 ms; `mandatory_pass/3` ~22 ms. (Before the review added the graph check and removed a second build from `mandatory_pass/3`: ~11, ~21 and -~30.) No threshold is set for graph cost — that is the owner's, against these numbers. +~30.) No threshold is set for graph cost; that is the owner's, against these numbers. ## Refused, by name @@ -120,19 +120,19 @@ review added the graph check and removed a second build from `mandatory_pass/3`: one thing that costs memory. - `all_paths/4` is `{:error, {:refused, :all_paths}}`, always. The number of paths between two nodes is exponential in the graph, and no cap makes enumerating them a question this - package should answer — a cap on that would be raised until it meant nothing. Motif + package should answer; a cap on that would be raised until it meant nothing. Motif isomorphism is not offered for the same reason. That is the boundary: what is cheap on the - BEAM — searches and dominators — is here; what is not is refused rather than attempted. + BEAM (searches and dominators) is here; what is not is refused rather than attempted. - An unknown option, or one the question cannot use (`{:unknown_option, key}`), an option of the wrong shape (`{:invalid, key, value}`), an edge kind outside the vocabulary, an empty - entry set, and a node id — as `from`, `to`, a gate, an entry or a target — the graph does + entry set, and a node id (as `from`, `to`, a gate, an entry or a target) the graph does not hold (`{:unknown_node, id}`) are each refused by name, before anything is built. The order, when more than one applies: the options first (an entry id the graph does not hold is an option fault, found here), then the edge cap, then the graph check, then the ids - given as arguments — so a graph over the cap is refused as over the cap whatever else is + given as arguments, so a graph over the cap is refused as over the cap whatever else is wrong with the call, and the O(V + E) check never runs on a graph the cap refuses. -- A graph `BeamMCP.Connectome.Graph.check/1` would refuse — a literal `%Graph{}` with a - dangling edge, a struct of the wrong shape — is refused as `{:error, {:invalid_graph, +- A graph `BeamMCP.Connectome.Graph.check/1` would refuse (a literal `%Graph{}` with a + dangling edge, a struct of the wrong shape) is refused as `{:error, {:invalid_graph, reason}}` with that function's reason, never answered: `:digraph` would drop the dangling edge without a word and the answer would describe a graph nobody handed in. @@ -159,5 +159,5 @@ Neither `a` nor `b` dominates `x` in either graph: each has the other as a way r It does not read signs; it does not replay an observed graph over a declared one (effective connectivity is a later release); it does not enumerate paths or match motifs; it does not -cache — every query is a fresh build over the graph it is handed, so a graph that changed +cache: every query is a fresh build over the graph it is handed, so a graph that changed between two calls gives two honest answers and no stale one. diff --git a/docs/connectome.md b/docs/connectome.md index 0db97555..38918c2d 100644 --- a/docs/connectome.md +++ b/docs/connectome.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# The connectome — vocabulary +# The connectome: vocabulary A **connectome** is the wiring diagram of a composed MCP system: which parts exist, and what can talk to what. It is a directed, typed graph, built before any message flows and again from what @@ -16,10 +16,10 @@ together. ## What the package is and is not beam_mcp renders authority; it never decides it. The connectome carries a sign slot so that a -graph can show what a policy allowed, denied or held — and the package itself writes only +graph can show what a policy allowed, denied or held, and the package itself writes only `:unset` into that slot. It populates no sign, signs no finding, holds no key, and makes no authority decision. Those belong to the host, behind the same `:authorize` and `:authorize_body` -hooks the transport already offers — where a host keeps its own risk tiers, approvals and +hooks the transport already offers, where a host keeps its own risk tiers, approvals and receipts, none of which this package holds. The connectome is not an MCP capability: neither protocol revision this package targets defines a topology or a declared-reachability primitive, and none is claimed. @@ -32,7 +32,7 @@ is claimed. prompts, the static call graph between the host's modules, and the host's boundary declarations. It says what *can* happen. - The **observed connectome** is built from what ran: telemetry on the dispatch path and, when the - host opts in, a guarded tracer. It records edge identity only — never arguments, results or + host opts in, a guarded tracer. It records edge identity only, never arguments, results or headers. It says what *did* happen, over a stated window. - A **drift finding** is an edge the observed connectome has and the declared connectome does not, or an edge both have with a sign supplied on both sides and the two different (`unset` on @@ -52,7 +52,7 @@ is claimed. | `:tool` | a tool the catalog names; labelled with its `command_class` and `mode` | | `:resource` | a resource the catalog names | | `:prompt` | a prompt the catalog names | -| `:process` | a BEAM process — a GenServer, a task, a supervisor | +| `:process` | a BEAM process: a GenServer, a task, a supervisor | | `:module` | a BEAM module, at the module level, or a module–function–arity at the finest level | ## Edge kinds @@ -72,16 +72,16 @@ A tool dispatch is an `:invoke` edge whose source is the server node. There is n | -- | -- | | `:allow` | the host's policy permits this edge | | `:deny` | the host's policy forbids it | -| `:hold` | the host's policy holds it for a decision it does not make alone — an advisory, never an approval | -| `:ungoverned` | a consumer looked and no gate — no rule of its policy, not a gate node of the reach page — applies to this edge: an affirmative statement, a supplied value like the three above; never a reason for the package to leave the edge out of anything | +| `:hold` | the host's policy holds it for a decision it does not make alone: an advisory, never an approval | +| `:ungoverned` | a consumer looked and no gate (no rule of its policy, not a gate node of the reach page) applies to this edge: an affirmative statement, a supplied value like the three above; never a reason for the package to leave the edge out of anything | | `:unset` | no sign has been supplied to this package; **the only value the package itself ever writes** | The sign is a *slot*. The package carries it so that a rendered graph can show a policy's verdict -beside each edge; a **consumer** fills it — the host that embeds this package, or any party +beside each edge; a **consumer** fills it: the host that embeds this package, or any party holding a graph it produced; the pages use the two words for the same role. Nothing in the package computes one. There is no setter: a consumer writes the struct field -(`%{edge | sign: :deny}`) and rebuilds the graph from its parts — -`BeamMCP.Connectome.Graph.new(nodes: g.nodes, edges: signed, schema_version: BeamMCP.Connectome.Graph.schema_version())` — or lets +(`%{edge | sign: :deny}`) and rebuilds the graph from its parts +(`BeamMCP.Connectome.Graph.new(nodes: g.nodes, edges: signed, schema_version: BeamMCP.Connectome.Graph.schema_version())`) or lets `BeamMCP.Connectome.Graph.check/1` or the encoder see it; each checks the value against the vocabulary and refuses, never corrects, anything else. **The bytes carry no field saying which consumer wrote a sign, or when**: the bytes' edge object is `from`, `to`, `kind`, @@ -89,12 +89,12 @@ consumer wrote a sign, or when**: the bytes' edge object is `from`, `to`, `kind` its time are the consumer's own record to keep, outside this package. `:unset` says exactly one thing: that nothing was handed here. It does not say that no policy -exists, that none spoke, or that none was computed — a host whose authority plane denied an +exists, that none spoke, or that none was computed: a host whose authority plane denied an edge, where that verdict never reached this package, gets `:unset` on that edge, and a graph that read `:unset` as "no policy spoke" would be wrong about the world. That is why the value is named for the slot's state and not for the world's. (Until 0.5.0 the value was `:unknown`, glossed "no policy has spoken"; the rename is the correction, and the bytes carry -`schema_version` `2` or later from here — `3` today — so a reader knows which vocabulary applies — +`schema_version` `2` or later from here (`3` today), so a reader knows which vocabulary applies: [`docs/connectome-canonical.md`](connectome-canonical.md).) There is no `:not_applicable` and no `:indeterminate`: nothing in the package can produce them, @@ -103,10 +103,10 @@ misuse. A sign means the same thing on a declared edge and on an observed one; t (`provenance`, `sign`) carries the whole fact, and no third value is added to say which side it came from: on a declared edge a sign is what a consumer wrote against the configuration; on an observed edge it is what a consumer wrote against the run. **The package never treats any -sign as suppression** — `:ungoverned` included: the diff records the edge exactly as it records +sign as suppression**, `:ungoverned` included: the diff records the edge exactly as it records any other (a sign appears in the diff record only in a changed-sign entry; every sign is in the graph's own bytes), and whether to suppress a finding is a consumer's decision, made in a -system that can say who decided and when — which this record, carrying no author and no time +system that can say who decided and when, which this record, carrying no author and no time of decision (its window is the observation's, the consumer's input), is not. A sign is also orthogonal to drift: an observed edge nobody declared is drift whatever its sign. @@ -154,7 +154,7 @@ module-level node and a module-function-arity is an `:mfa`-level node. `BeamMCP.Connectome.Node.id/1` is the one place an identity becomes an id, and this is what it writes. The components are written the server first, then the identity's tag (its first element: `boundary` for a boundary identity, whose node kind is `module`), then the -rest of the identity in order — not the tuple's order, which puts the tag first — each +rest of the identity in order (not the tuple's order, which puts the tag first), each escaped and then joined by `/`. Escaping is `%` to `%25` first, then `/` to `%2F`, applied to every component, so a `/` inside a server name or a resource URI never reads as a separator. An atom naming a kind or a source is written as its name; a module is written as Elixir @@ -197,7 +197,7 @@ An edge carries exactly one provenance. Comparing the two graphs is how a drift | `:sha384` | SHA-384, by option; 48 bytes, 96 characters | | `:sha512` | SHA-512, by option; 64 bytes, 128 characters | -A canonical envelope — the graph's and the diff record's — names one algorithm in its bytes, +A canonical envelope (the graph's and the diff record's) names one algorithm in its bytes, and its hash is that digest over exactly those bytes; a verifier reads the name from the bytes (`docs/connectome-canonical.md`, rule 9). The three are the whole list: the package refuses any other name at the option, before a byte is written. The package holds no key and makes no @@ -222,6 +222,6 @@ asserts, whatever the name, and a signer package attached here is what makes any ## Weight -An edge may carry a weight — an observed call count, a latency summary. Weights are measurements. +An edge may carry a weight: an observed call count, a latency summary. Weights are measurements. They are never part of the declared connectome's canonical bytes, so the hash of a declared connectome is a claim about wiring and nothing else. diff --git a/docs/crypto-posture.md b/docs/crypto-posture.md index ed69c626..8160d513 100644 --- a/docs/crypto-posture.md +++ b/docs/crypto-posture.md @@ -21,8 +21,8 @@ the caller's option and never a literal. Every other `:crypto.` function, every calls a crypto function other than :crypto.hash/2" and ":crypto.hash/2 is called at one site, over canonical bytes, with the algorithm a variable"). -**Three digests, named in the bytes.** A canonical envelope — the graph's and the diff -record's — carries `"algorithm"` as a member: `"sha256"`, `"sha384"` or `"sha512"`, the SHA-2 +**Three digests, named in the bytes.** A canonical envelope (the graph's and the diff +record's) carries `"algorithm"` as a member: `"sha256"`, `"sha384"` or `"sha512"`, the SHA-2 family of FIPS 180-4, and its hash is that digest over exactly its bytes, so a verifier reads the algorithm from what it holds rather than from a page or a release note ([`docs/connectome-canonical.md`](connectome-canonical.md), rules 1 and 9). SHA-256 is the @@ -44,7 +44,7 @@ defined in the document", which holds the three names to `docs/connectome.md`). **Why the algorithm is under the hash.** The member is part of the bytes, so two envelopes of one graph naming different digests are different bytes with different hashes; neither can be passed off as the other, and an attacker who can rewrite the member can rewrite the graph -anyway — the hash is over all of it. What the bytes cannot do is decide which of the three a +anyway: the hash is over all of it. What the bytes cannot do is decide which of the three a verifier accepts: that is the verifier's policy, stated in the verifier, and this package neither asks nor answers it. @@ -58,8 +58,8 @@ neither asks nor answers it. (entry 3; `test/beam_mcp/boundary/no_signature_test.exs` "no line under lib/ calls a signing or MAC primitive"). What is signed is the canonical bytes, with a key the consumer holds, through one seam: `BeamMCP.Signer`, a behaviour with exactly one callback, `sign(canonical_bytes, -opts)` — two arguments with those names, `{:ok, signature}` or `{:error, reason}`, pinned by -census so that any widening is a visible act — and `BeamMCP.Connectome.Canonical.signature/3`, +opts)` (two arguments with those names, `{:ok, signature}` or `{:error, reason}`, pinned by +census so that any widening is a visible act), and `BeamMCP.Connectome.Canonical.signature/3`, the one site that calls it, over the bytes `encode/2` produces, returning the signature beside them and moving no byte. The one implementation here, `BeamMCP.Signer.None`, signs nothing; the reference implementation that does, Ed25519 through OTP's `:crypto` with a key the host diff --git a/docs/fips.md b/docs/fips.md index ae42ae1d..62d113d2 100644 --- a/docs/fips.md +++ b/docs/fips.md @@ -18,10 +18,12 @@ Only that `:crypto.hash/2` answer for `:sha256`, `:sha384` and `:sha512`. Those digests of FIPS 180-4, approved under FIPS 140-3, and they are the only three names the canonical envelope may carry ([`docs/crypto-posture.md`](crypto-posture.md)). Nothing this package calls is disallowed in FIPS mode: it uses no MD5, no SHA-1, no cipher, no MAC, no -key derivation and no random source — `:crypto.hash/2` at one site is its whole use of the +key derivation and no random source: `:crypto.hash/2` at one site is its whole use of the library (`test/beam_mcp/boundary/no_key_holding_test.exs`, both tests). A host that has enabled FIPS mode therefore runs this package unchanged, and a graph hashed under FIPS mode -has the same bytes and the same hash as one hashed outside it: the digest is the digest. +has the same bytes and the same hash as one hashed outside it: the digest is the digest. Both +are measured, not only reasoned: the whole suite passes in FIPS mode, the canonical-hash +goldens included (below, "Measured"). ## What a FIPS-mode host needs, and this package does not provide @@ -35,22 +37,30 @@ parameters), restated; where this page and OTP's differ, OTP's is right. validated** on that machine. `:crypto.info/0` reports `fips_provider_available: true` when the provider is there. `:crypto.info_fips/0` answers, in OTP's words, `:enabled` when running in FIPS mode or `:not_enabled` if `crypto` was built with FIPS support, and - "for other builds this value is always `:not_supported`" — so `:not_supported` names the + "for other builds this value is always `:not_supported`", so `:not_supported` names the build, not the provider: a FIPS-built `crypto` over a library with no provider answers - `:not_enabled` — when FIPS mode was not requested; when it was, see the next step, since + `:not_enabled`, when FIPS mode was not requested; when it was, see the next step, since OTP does not load at all. Measured on the development runtime that wrote this page (OTP 28.1.1, OpenSSL 3.0.13, no FIPS provider): `:not_supported`, `fips_provider_available: false`, and `:crypto.enable_fips_mode(true)` answers `false`. 2. **The `crypto` configuration parameter `fips_mode: true`, in the application environment - before the `crypto` module is first loaded** — in `sys.config` or the release's + before the `crypto` module is first loaded**, in `sys.config` or the release's configuration, not set at runtime after the fact. In OTP's words, "this setting will take effect when the nif module is loaded", and: **"if FIPS mode is requested but not available at run time the nif module and thus the crypto module will fail to load. This mechanism prevents the accidental use of non-validated algorithms."** So a host that sets - the parameter on a runtime without a validated provider does not get `:not_enabled` — it - gets no `crypto` at all: `application:start(crypto)` fails, and a release whose `.app` - requires `crypto` (this package's does) does not boot. That is the failure a FIPS host - wants, and it comes before any check this page could suggest. (This page's own inference + the parameter on a runtime without a validated provider does not get `:not_enabled`; it + gets no `crypto` module at all. **Measured (2026-09-23, OTP 28.1.1 built with FIPS + support, the provider configuration withheld): the module's `on_load` fails ("Library + load-call unsuccessful") and an error report says so, but `application:start(crypto)` and + `application:ensure_all_started(crypto)` still answer `ok`**, since the `crypto` + application has no callback module to fail. So a release whose `.app` requires `crypto` + (this package's does) boots, and the first `:crypto` call raises `UndefinedFunctionError`: + this package's `:crypto.hash/2` raises then, and nothing is hashed without the provider. + An earlier version of this page said the application fails to start and the release does + not boot; that was reasoned from OTP's words and the measurement corrected it. The + refusal is real but arrives at the first call, not at boot, which is why the check in + step 3 matters. (This page's own inference from OTP's `on_load`, not OTP's words: the parameter is read once, when the module loads, so a value set after that changes nothing until the module is loaded again.) This is why OTP says `crypto:start/0` "does not work if FIPS mode is to be enabled" and to use @@ -58,34 +68,59 @@ parameters), restated; where this page and OTP's differ, OTP's is right. environment, before the module is. A release does this for every application its `.app` files require, and this package's `.app` lists `crypto` as a required application, so a release that includes `beam_mcp` loads and starts it (it did not until 0.6.0, the release - this page arrives in — an HTTP host had `crypto` only through `plug` and + this page arrives in: an HTTP host had `crypto` only through `plug` and `bandit`, both optional, and a stdio-only release would have had no `:crypto.hash/2` at all; writing this page found it, and `test/beam_mcp/connectome/canonical_test.exs` "the .app the build writes depends on crypto, so a release without plug and bandit still hashes" holds it). -3. **A check at start, for the belt beside OTP's braces.** `:crypto.info_fips/0` answers - `:enabled` once the parameter took; a host that requires FIPS mode may check it at start - and refuse to serve otherwise, though under the parameter OTP's own refusal (the module - not loading) comes first. The +3. **A check at start.** `:crypto.info_fips/0` answers `:enabled` once the parameter took; a + host that requires FIPS mode checks it at start and refuses to serve otherwise, because + OTP's own refusal (the module not loading) surfaces only at the first `:crypto` call, not + at boot (measured, step 2). Load `crypto` before the check and apart from it: in Elixir, + expanding a remote call to `:crypto` loads the module, so a check written in the same + expression as the load can read a `crypto` loaded before `fips_mode` applied. The older way, `:crypto.enable_fips_mode(true)` at runtime (`true` when it took, `false` - when it did not), **is deprecated in OTP 28** — "use config parameter fips_mode", in - the deprecation's own words — and is named here only so a host reading older guidance + when it did not), **is deprecated in OTP 28** ("use config parameter fips_mode", in + the deprecation's own words) and is named here only so a host reading older guidance knows what replaced it. This package calls none of `:crypto.enable_fips_mode/1`, `:crypto.info_fips/0` or -`:crypto.info/0` — the three are barred from `lib/` by the same census that bars every -`:crypto.` function but `hash/2` — and it sets no application environment: the +`:crypto.info/0` (the three are barred from `lib/` by the same census that bars every +`:crypto.` function but `hash/2`), and it sets no application environment: the package-reach census (`test/beam_mcp/boundary/package_reach_test.exs`) pins the functions called on `Application` to `load/1` and `spec/2`, reads only, so the package cannot enable -FIPS mode by accident or on purpose, and cannot report on it — the host's configuration and +FIPS mode by accident or on purpose, and cannot report on it; the host's configuration and start-up are where that lives. Nothing here is a claim that this package, or a host running it, is FIPS-validated: validation is a property of a cryptographic module (the OpenSSL FIPS provider) and of the process that certified it, and this package holds no such module. +## Measured: this package in FIPS mode + +`.github/workflows/fips.yml` runs the suite on every push to `main`, on each pull request that +touches the code, and weekly, on this toolchain: Erlang/OTP 28.1.1 (the pinned line) built +with `--enable-fips`, over OpenSSL 3.5.8's libcrypto with the FIPS provider built from +OpenSSL 3.1.2, the source that CMVP certificate #4985 (FIPS 140-3) validates, following its +security policy (`enable-fips`, `make install_fips`, `openssl fipsinstall` on the machine +that runs it). Before any test runs, the same VM asserts `:crypto.info_fips()` is `:enabled` +and the provider reports build `3.1.2`; a negative control shows that without the provider +configuration `fips_mode true` gives no working `crypto`. + +Measured 2026-09-23 on that toolchain: `:crypto.info_fips()` answers `:enabled`, +`fips_provider_available: true`, `fips_provider_buildinfo: "3.1.2"`, the linked library +OpenSSL 3.5.8; `:crypto.hash(:md5, _)` raises `notsup` ("Bad digest type in FIPS"); +SHA-256, SHA-384 and SHA-512 answer, SHA-256 of `abc` being the FIPS 180-4 test vector; and +the suite passes, 11 properties and 729 tests, 0 failures. A FIPS-built OTP started without +`fips_mode` answers `:not_enabled`, as step 1 says. + +**This is not a validation claim.** Validation belongs to a cryptographic module and the +operational environments its certificate names; the CI runner (ubuntu-24.04) is not one of +#4985's, and a host's FIPS posture is its own build of OTP and OpenSSL on its own platform. +What the job proves is that this package's behaviour does not change in FIPS mode. + ## What FIPS mode changes for a consumer Nothing about the bytes or the hash. A verifier in FIPS mode and one outside it compute the same digest over the same bytes; the algorithm the envelope names is one the FIPS provider serves. A consumer whose policy accepts only SHA-384 or SHA-512 asks the host for that -`algorithm:` and refuses envelopes naming another — the consumer's policy, stated in the +`algorithm:` and refuses envelopes naming another: the consumer's policy, stated in the consumer ([`docs/connectome-canonical.md`](connectome-canonical.md), rule 9). diff --git a/docs/governance.md b/docs/governance.md index 16df7f69..a82c256b 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -5,8 +5,8 @@ SPDX-License-Identifier: Apache-2.0 # Governance -Who decides what, how a change lands, and what an outside measurement — the OpenSSF -Scorecard — reads of it. Written to state what is true of this repository today, including +Who decides what, how a change lands, and what an outside measurement (the OpenSSF +Scorecard) reads of it. Written to state what is true of this repository today, including the parts a larger project would have and this one does not. ## Who @@ -27,14 +27,14 @@ would need. Every change, the maintainer's included, goes the same way: 1. **A branch and a pull request.** `main` is protected by a ruleset: no direct push, no - force-push, no deletion, linear history, and two required status checks — the DCO sign-off + force-push, no deletion, linear history, and two required status checks: the DCO sign-off and the quality gate. A pull request is the only way onto `main`, and it is rebased, never merged, so the history is a line. -2. **The gate.** `tools/gate.sh` runs the same sixteen steps locally and in CI — format, +2. **The gate.** `tools/gate.sh` runs the same sixteen steps locally and in CI (format, compile with warnings as errors, Dialyzer, the instruments' parse, the suite, Credo, the properties, the optional-dependency probe, the dependency audit, the benchmarks, the docs, REUSE, the licence files, the publication census, the public-API baseline against - `origin/main` and the commit-message terms — on three + `origin/main` and the commit-message terms) on three OTP/Elixir pairs (the floor, the pinned line, the newest). There is no baseline to hold: a non-zero count is a failure. 3. **Review by tier, then the merge word.** `CONVENTIONS.md` states the tier rule: a contract @@ -55,7 +55,7 @@ Every change, the maintainer's included, goes the same way: The [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/ScriptKittyOS/beam_mcp) runs on every push to `main` and once a week (`.github/workflows/scorecard.yml`) and publishes its result. It is a measurement of the tree and the platform, and this page says which of its -checks this project keeps on purpose, which it cannot, and which it has decided against — so a +checks this project keeps on purpose, which it cannot, and which it has decided against, so a reader does not have to guess whether a low mark is neglect or a decision. The figures are the Scorecard's own, read from `api.scorecard.dev` on 2026-09-19 (aggregate @@ -64,29 +64,29 @@ promised, and moves when the Scorecard next runs. | check | this repository | why | measured 2026-09-19 | | --- | --- | --- | --- | -| Pinned-Dependencies | every workflow action pinned by commit SHA with its version beside it; Mix dependencies locked in `mix.lock` | a tag can be moved, a SHA cannot; a test holds the pins on every push | 10 — "all dependencies are pinned" | -| Token-Permissions | every workflow declares top-level `permissions:` with no write; the two jobs that write (provenance's attestation, the Scorecard's SARIF upload) hold it at the job, and no other job does | least privilege, held by the same test — the top level, and which jobs may write | 10 | -| Branch-Protection | the ruleset above: no direct push, linear history, required checks | kept; **no required reviewer** — see Code-Review | 4 — "not maximal": the missing tiers are the required reviewer and a second approver, which one maintainer cannot supply | -| Code-Review | pull requests, every one since the ruleset (2026-09-06; the eight bootstrap commits before it were pushed directly); the reviewer of record is the maintainer, after the lanes | one maintainer cannot approve their own pull request under GitHub's rules, and there is no second one; the Scorecard scores this low and that is the true state, not an omission | 0 — "0/7 approved changesets" | +| Pinned-Dependencies | every workflow action pinned by commit SHA with its version beside it; Mix dependencies locked in `mix.lock` | a tag can be moved, a SHA cannot; a test holds the pins on every push | 10, "all dependencies are pinned" | +| Token-Permissions | every workflow declares top-level `permissions:` with no write; the two jobs that write (provenance's attestation, the Scorecard's SARIF upload) hold it at the job, and no other job does | least privilege, held by the same test: the top level, and which jobs may write | 10 | +| Branch-Protection | the ruleset above: no direct push, linear history, required checks | kept; **no required reviewer**; see Code-Review | 4, "not maximal": the missing tiers are the required reviewer and a second approver, which one maintainer cannot supply | +| Code-Review | pull requests, every one since the ruleset (2026-09-06; the eight bootstrap commits before it were pushed directly); the reviewer of record is the maintainer, after the lanes | one maintainer cannot approve their own pull request under GitHub's rules, and there is no second one; the Scorecard scores this low and that is the true state, not an omission | 0, "0/7 approved changesets" | | Security-Policy | `SECURITY.md` | the intake, the rubric and the CVE path | 10 | | License | `LICENSE`, `NOTICE`, `LICENSES/`, REUSE headers on every file, held by the gate | | 10 | | Dependency-Update-Tool | Dependabot, weekly, Mix and GitHub Actions | | 10 | | Vulnerabilities | `mix hex.audit` in the gate, OSV-fed, on every push | | 10 | -| CI-Tests | the gate on three OTP/Elixir pairs | | 10 — "7 out of 7 merged PRs checked" | -| Maintained | commits and releases as the CHANGELOG shows | the check scores **0 for any repository younger than 90 days**, whatever its activity; this one was created 2026-09-06, so the figure is the rule's until 2026-12-05 and says nothing about the tree | 0 — "created within the last 90 days" | -| Signed-Releases | releases are Hex releases: from `0.6.0` on, the tarball is built by CI on the tag and attested (`docs/provenance.md`; `0.5.0` and earlier carry none); there are no GitHub Releases with assets for this check to read | the attestation binds to the checksum hex.pm shows, which is where consumers fetch from; a GitHub Release would be a copy | −1 — "no releases found": the check reads GitHub Releases only | -| Packaging | the package is published to hex.pm by the maintainer from the canonical tarball, on a signed tag; no GitHub Actions publishing workflow | the publish step holds a Hex API key, which stays on the maintainer's seat rather than in a workflow secret — a decision, recorded here; the provenance workflow attests the bytes but does not publish them | −1 — "packaging workflow not detected": the check reads a publishing workflow only | -| SAST | Dialyzer and Credo in the gate; no CodeQL | the gate's analysers are what the language has; a CodeQL workflow is a separate decision and is not taken here | 0 — the check recognises neither Dialyzer nor Credo | -| Fuzzing | eleven property-based tests in the gate; no OSS-Fuzz | property tests are the fuzzing the suite does; OSS-Fuzz integration is not taken | 10 — "project is fuzzed": the check reads the property tests as fuzzing | +| CI-Tests | the gate on three OTP/Elixir pairs | | 10, "7 out of 7 merged PRs checked" | +| Maintained | commits and releases as the CHANGELOG shows | the check scores **0 for any repository younger than 90 days**, whatever its activity; this one was created 2026-09-06, so the figure is the rule's until 2026-12-05 and says nothing about the tree | 0, "created within the last 90 days" | +| Signed-Releases | releases are Hex releases: from `0.6.0` on, the tarball is built by CI on the tag and attested (`docs/provenance.md`; `0.5.0` and earlier carry none); there are no GitHub Releases with assets for this check to read | the attestation binds to the checksum hex.pm shows, which is where consumers fetch from; a GitHub Release would be a copy | −1, "no releases found": the check reads GitHub Releases only | +| Packaging | the package is published to hex.pm by the maintainer from the canonical tarball, on a signed tag; no GitHub Actions publishing workflow | the publish step holds a Hex API key, which stays on the maintainer's seat rather than in a workflow secret: a decision, recorded here; the provenance workflow attests the bytes but does not publish them | −1, "packaging workflow not detected": the check reads a publishing workflow only | +| SAST | Dialyzer and Credo in the gate; no CodeQL | the gate's analysers are what the language has; a CodeQL workflow is a separate decision and is not taken here | 0, the check recognises neither Dialyzer nor Credo | +| Fuzzing | eleven property-based tests in the gate; no OSS-Fuzz | property tests are the fuzzing the suite does; OSS-Fuzz integration is not taken | 10, "project is fuzzed": the check reads the property tests as fuzzing | | CII-Best-Practices | **silver** since 2026-09-23 ([project 14774](https://www.bestpractices.dev/projects/14774)), passing the same day; continuity of access met by the two people `docs/succession.md` names; every answer cites this tree, and one that stops being true is changed there | the badge is a self-assessment a reader can check line by line; the bus factor (a SHOULD) is answered Unmet, since access is held twice and knowledge once | 2, "badge detected: InProgress" (the run of 2026-09-23 17:26 UTC, before passing was recorded; the check gives passing 5, silver 7, gold 10) | | Dangerous-Workflow | the pull-request body is read through an environment variable, never interpolated into a script | | 10 | | Binary-Artifacts | none in the tree | | 10 | -| Contributors | one organization owns the repository; the NOTICE names the owner and the builder | the check counts the companies commit authors declare, and read two | 6 — "2 contributing companies or organizations" | +| Contributors | one organization owns the repository; the NOTICE names the owner and the builder | the check counts the companies commit authors declare, and read two | 6, "2 contributing companies or organizations" | ## What is deliberately not done No second maintainer is invented to satisfy a check. No GitHub Release is published beside the Hex release to satisfy a check. No analyser is added for its name. Where a check reads -low for a reason this page states, the reason stands until the fact changes — a second +low for a reason this page states, the reason stands until the fact changes: a second maintainer is `docs/succession.md`'s subject, and the off-account archive that would lower the bus factor's cost waits on a decision recorded there. diff --git a/docs/provenance.md b/docs/provenance.md index 6161514f..cad46138 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -24,24 +24,24 @@ package page. The owner tags and publishes; the workflow attests and never publi the machine's, not the commit's: each entry carries the file's on-disk **mode** (a checkout under umask 002 gives `664`, under 022 gives `644`), and a directory named in `files:` is walked in **readdir order** (ext4's per-filesystem hash order; tmpfs's another). Measured on -2026-09-17: one commit gave three checksums — this machine's tree, a umask-022 checkout, a -tmpfs checkout — with the same 39 files inside. A tarball built from a working tree is that +2026-09-17: one commit gave three checksums (this machine's tree, a umask-022 checkout, a +tmpfs checkout) with the same 39 files inside. A tarball built from a working tree is that machine's; an attestation over it would describe bytes no other machine reproduces. -So the release tarball is built by one script on every seat — CI, the publisher, a stranger +So the release tarball is built by one script on every seat: CI, the publisher, a stranger reproducing it: ```sh -tools/release_tarball.sh v0.7.0 beam_mcp-0.7.0.tar +tools/release_tarball.sh v0.10.0 beam_mcp-0.10.0.tar ``` It `git archive`s the ref with `tar.umask=022` (every file `644` whatever the machine's umask, -and only tracked files — a draft under `docs/` cannot ship), extracts with permissions +and only tracked files; a draft under `docs/` cannot ship), extracts with permissions preserved, resolves the lock's dependencies, and runs `mix hex.build` there; `mix.exs` names its `files:` as globs, so the entry order is `Path.wildcard`'s sort and not a filesystem's. Measured: the same commit built this way on ext4 and on tmpfs gave one tarball, byte for -byte, every entry `644`. A test builds it and pins the structure — the modes, the order, the -checksum — on every run of the suite. +byte, every entry `644`. A test builds it and pins the structure (the modes, the order, the +checksum) on every run of the suite. **A release verifies only if it was published with the same script** (`--publish` runs `mix hex.publish` from the canonical tree). The tag's run downloads the bytes hex.pm serves @@ -53,19 +53,19 @@ failure, not as a pass. ## Verify a published tarball ```sh -v=0.7.0 +v=0.10.0 curl -fsSLO "https://repo.hex.pm/tarballs/beam_mcp-${v}.tar" gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp ``` -`gh attestation` needs GitHub CLI 2.49 or newer (Ubuntu's packaged 2.45 does not have it — +`gh attestation` needs GitHub CLI 2.49 or newer (Ubuntu's packaged 2.45 does not have it, measured here), and it is the verifier GitHub documents; the attestation is a Sigstore bundle (`gh attestation download` fetches it; the repository's Actions tab lists each one under *Attestations*), so another Sigstore verifier can read it, but no such path is measured here and none is claimed. What a verifier proves: the tarball's digest is the one a run of `provenance.yml` at a named commit of this repository produced, signed through Sigstore at the time. What it does not prove: -anything about that commit's contents — that is the tree's own record (the gate, the review +anything about that commit's contents; that is the tree's own record (the gate, the review record), reachable from the commit the attestation names. ## Verify a release tag's signature @@ -90,6 +90,31 @@ tarball hex.pm serves. The private key is held on the maintainer's own machine, or hex.pm, which only distribute. If the key is ever replaced, this page and the CHANGELOG say so in the same commit, with the new fingerprint. +## The SBOM + +From `0.10.0` each release also carries a software bill of materials: CycloneDX 1.6, JSON, the +runtime dependency set (the Hex packages at their locked versions, and the OTP and Elixir +applications), generated from the tag's tree by `tools/sbom.sh` with the EEF's `mix_sbom` +(pinned by version and by the release asset's SHA-256; nothing is added to `mix.exs` or +`mix.lock`) and attested to the same tarball digest as the provenance. `plug` and `bandit` +appear although they are optional dependencies of this package: the tool has no notion of an +optional dependency. One workaround is named in the script (G-087 in this project's records: +the tool's 0.11.0 cannot read the `tools: :optional` entry in `mix.exs`, so its scratch copy +reads `:tools`; the component list is the same). + +```sh +gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \ + --predicate-type https://cyclonedx.org/bom +gh attestation download "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \ + --predicate-type https://cyclonedx.org/bom # writes sha256:.jsonl +jq -r '.dsseEnvelope.payload' sha256:*.jsonl | base64 -d | jq '.predicate' > "beam_mcp-${v}.cdx.json" +``` + +The release workflow runs the download and the extraction above on every tag and compares the +result with the document it generated, so the commands are the ones measured. The SBOM is not +byte-reproducible (CycloneDX gives each document a fresh serial number and timestamp); what +binds it to the package is the attestation over the tarball's digest. + ## Reproduce the bytes Trust nothing above; build it: @@ -101,7 +126,7 @@ tools/release_tarball.sh "v${v}" "rebuilt-${v}.tar" # prints the sha256 = th The script needs Elixir, Erlang, Hex 2.x, bash, git and `sha256sum` or `shasum`. CI builds with the pair `.tool-versions` names, copied into the workflow (Elixir 1.18 on OTP 28); the -tarball carries no compiled code, and Hex is what packages it — 2.4.0 and 2.5.1 gave +tarball carries no compiled code, and Hex is what packages it: 2.4.0 and 2.5.1 gave byte-identical tarballs of one commit (measured), and a packaging change in a later Hex would show as a checksum the tag's run fails to match, not as a silent difference. The canonical bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test @@ -111,7 +136,7 @@ holds every packaged name to ASCII. `0.5.0` and earlier carry no attestation and were built from working trees: their bytes are the publishing machine's (the 0.5.0 tarball's entries carry `664`), reproducible on that -machine — measured for 0.5.0 — and not by the script above, which builds `644` entries in glob +machine (measured for 0.5.0) and not by the script above, which builds `644` entries in glob order. Their provenance is the tag and the checksum, nothing more. ## Hex's own transparency log diff --git a/docs/roadmap.md b/docs/roadmap.md index 141d52d5..7b812111 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,17 +10,13 @@ gathers what `UPGRADING.md`, the README and `docs/will-not-implement.md` already place. The order is firm; the dates are not promised. When this page and one of those disagree, those are the record and this page is corrected. -## Next: `0.10.0`, the quiet minor +## Now: `0.10.0`, the quiet minor -Instruments and pages, no public entry added, removed or changed: +Instruments and pages, no public entry added, removed or changed: the suite in FIPS mode in CI +on the validated OpenSSL FIPS provider (`docs/fips.md`, "Measured"); a CycloneDX SBOM +attested to each release tarball (`docs/provenance.md`); the documentation free of em dashes. -- a FIPS leg in CI, exercising the package on a FIPS-mode OpenSSL (`docs/fips.md` states the - posture today); -- a software bill of materials attached at each release, beside the provenance attestation - (`docs/provenance.md`); -- a copy sweep of the documentation. - -## Then: `1.0.0`, the freeze +## Next: `1.0.0`, the freeze `1.0.0` follows once the public API and the stated threat model have each survived a full minor release unchanged (the README's condition). From `1.0.0`, `docs/public-api.txt` is @@ -35,6 +31,8 @@ Decided, in this order, each additive so no `1.x` consumer is broken by it: 1. **A federation seam**, for merging connectome graphs from several nodes. The trust questions it must answer first are listed in `docs/threat-model.md` ("Federation"). 2. **Effective connectivity**: the observed graph weighted into the declared one. +3. **The Tasks extension** of the `2026-07-28` revision: not built today and not refused + either; an addition, like the two above. Throughout: security fixes on the latest minor as `SECURITY.md` commits, dependency updates through Dependabot, and the protocol revisions the MCP specification publishes, tracked as diff --git a/docs/succession.md b/docs/succession.md index 2542243d..3024d6d3 100644 --- a/docs/succession.md +++ b/docs/succession.md @@ -49,7 +49,7 @@ Support and hex.pm's support process. ## What survives the maintainer today, and what does not -**Survives:** the public repository under the organization — public and forkable whatever +**Survives:** the public repository under the organization, public and forkable whatever happens to any account; if the maintainer *stops*, they add a successor as the organization's owner first, which is a smaller act than transferring a personal repository; the published Hex releases and their documentation on hexdocs, which stay fetchable whether or not anyone diff --git a/docs/threat-model.md b/docs/threat-model.md index 3b795443..049a6937 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -5,24 +5,24 @@ SPDX-License-Identifier: Apache-2.0 # The threat model -What this package defends, against whom, and by which test — for the whole package. It +What this package defends, against whom, and by which test, for the whole package. It extends the model the tracer shipped with in 0.4.0 (`docs/connectome-observed.md`, "The threat model, which is the boundary of every claim in this section"; the `BeamMCP.Connectome.Tracer` -moduledoc) rather than replacing it: that paragraph's line — accident and failure on a node +moduledoc) rather than replacing it: that paragraph's line (accident and failure on a node running only host-installed code in scope, an adversary executing code inside the same node -out — is the line here too, drawn once for every module. What this page adds is the wire: a +out) is the line here too, drawn once for every module. What this page adds is the wire: a client this package has never met, sending bytes it did not write, and what is refused, what is bounded, and what is handed to the HTTP server or the host by decision. **An MCP server library owes the wire, not the model.** What a tool's result does to a -language model that reads it — prompt injection through tool results or resource contents, -OWASP LLM01:2025 — is the host's: this package carries the host's bytes to the client +language model that reads it (prompt injection through tool results or resource contents, +OWASP LLM01:2025) is the host's: this package carries the host's bytes to the client verbatim and never interprets them, so it can neither inject nor filter. Everything below is about the bytes on the way in. Every row of the wire table names the test that enforces it, by path and by name, and a census holds each citation to the tree -(`test/beam_mcp/threat_model_test.exs` "every test the page cites exists, by path and by name") — the discipline `docs/will-not-implement.md` is held +(`test/beam_mcp/threat_model_test.exs` "every test the page cites exists, by path and by name"), the discipline `docs/will-not-implement.md` is held by. Every number is a measurement, with its date; a number that moves is re-measured, not edited. @@ -30,44 +30,44 @@ edited. | party | trusted for | not trusted for | |---|---|---| -| **the host** — the application that embeds this package | everything: the catalog and the dispatch function it supplies, its `authorize/1` and `authorize_body/2` hooks, the HTTP server and its settings, what it does with a tool's result, the node it runs on | nothing is checked against the host; a host fault is answered as a host fault (`500`, `-32603`) and told to nobody else | -| **the client** on the wire — stdio or HTTP | nothing | every byte: read under a bound, decoded under a bound, matched to the headers it sent, validated against the schema the host declared, refused by name when any of that fails | -| **the node** — every process in the same BEAM | everything, because the BEAM has no in-node isolation | out of scope by physics: see below | -| **this package** | to hold no tool, no key, no signature, no session, no authority, no client, no state between requests, and to claim no capability the specification does not define — each a census under `test/beam_mcp/boundary/`, listed on `docs/will-not-implement.md` | to be a security boundary against the node it runs in | -| **a federation peer** — another node's graph, when the seam exists | not yet defined: the seam is unbuilt (see "Federation") | until the seam states it: everything | +| **the host**: the application that embeds this package | everything: the catalog and the dispatch function it supplies, its `authorize/1` and `authorize_body/2` hooks, the HTTP server and its settings, what it does with a tool's result, the node it runs on | nothing is checked against the host; a host fault is answered as a host fault (`500`, `-32603`) and told to nobody else | +| **the client** on the wire: stdio or HTTP | nothing | every byte: read under a bound, decoded under a bound, matched to the headers it sent, validated against the schema the host declared, refused by name when any of that fails | +| **the node**: every process in the same BEAM | everything, because the BEAM has no in-node isolation | out of scope by physics: see below | +| **this package** | to hold no tool, no key, no signature, no session, no authority, no client, no state between requests, and to claim no capability the specification does not define, each a census under `test/beam_mcp/boundary/`, listed on `docs/will-not-implement.md` | to be a security boundary against the node it runs in | +| **a federation peer**: another node's graph, when the seam exists | not yet defined: the seam is unbuilt (see "Federation") | until the seam states it: everything | ## The wire, vector by vector -Each vector is one of three things: **REFUSED** — this package answers by name and the -request goes no further; **BOUNDED** — this package caps what a request can cost it and -says the cap; **DELEGATED** — the HTTP server or the host holds the line, and this page says +Each vector is one of three things: **REFUSED**: this package answers by name and the +request goes no further; **BOUNDED**: this package caps what a request can cost it and +says the cap; **DELEGATED**: the HTTP server or the host holds the line, and this page says which setting. Measurements are from one 32-scheduler machine, OTP 28, on the dates given. The OWASP column names the entry each vector answers to: `ASInn` from the *OWASP Top 10 for Agentic -Applications for 2026* (published 2025-12-09), `LLMnn:2025` from the 2025 LLM Top 10 — a +Applications for 2026* (published 2025-12-09), `LLMnn:2025` from the 2025 LLM Top 10: a reading of each entry's title, not a claim of coverage. | vector | this package | what happens, measured | enforced by | OWASP | |---|---|---|---|---| -| **Oversized body** | BOUNDED, then REFUSED | Each transport caps one body at 1 MiB — exactly 1,048,576 bytes admitted, the byte past it refused, on the HTTP body, the stdio line and the legacy `Content-Length` frame alike (the stdio line counts a trailing `\r` before trimming it, so a CRLF client has one byte less of payload; MCP's framing is LF). Over HTTP/1 the server reads exactly the cap — 1,048,576 bytes, constant across six socket-buffer settings (measured 2026-09-07) — and answers `413` / `-32600` with `connection: close`, so the HTTP server does not drain the rest on behalf of a caller already refused; over HTTP/2 the adapter hands whole frames, so the read is the cap plus the frame that crosses it (at most 16 KiB, the adapter's frame size — 1,048,576 + 16,384 measured by a review lane, 2026-09-16), the `413` is answered at that frame, not at the stream's end or the deadline, and it carries no `connection: close` (the stream ends with the response) (a mutant that gave the size rule a piece of slack held a 64 KiB overrun and answered `408` instead; the pin is HTTP/2's alone, since over HTTP/1 a read never asks past the cap). Over stdio a line past the frame bound is refused as it is read — `-32600` "Request line exceeds 1048576 bytes", the code the HTTP refusal carries, so one vector has one code — the line held as one off-heap binary that the loop retains nothing of (a list of one-byte binaries had cost 46–67 MiB of heap per 1 MiB line, and the legacy `Content-Length` check downcased the whole line for a fifteen-byte prefix, 40 MiB more; 0 MiB sampled at 1 ms during a 1 MiB read now, 2026-09-16), never buffered past the bound, and the rest of the line is drained to its newline so no tail of it is read as the next frame (until this page, the tail was); a legacy `Content-Length` frame declaring more than the cap is refused by name (`-32600` "Request frame exceeds 1048576 bytes") and its declared body drained in chunks, never buffered; every header line of that block is read under the same line bound, and past it the block and its body are drained (a lane sent 64 MiB on one header line and it was read whole) — until this page the body was left on the pipe and a request inside it was dispatched (a lane put one there and watched it answer). | `test/beam_mcp/transport/http_test.exs` "a body over the cap is refused and the connection closed"; `test/beam_mcp/transport/http_bandit_test.exs` "over HTTP/2 a body is refused at the frame that crosses the cap, not at the stream's end"; `test/beam_mcp/readme_claims_test.exs` "the 1 MiB cap is the number the code enforces" "the server-side read before a refusal is the cap itself, not a range"; `test/beam_mcp/transport/stdio_test.exs` "a line beyond the frame bound is refused rather than buffered"; `test/beam_mcp/threat_model_test.exs` "a line past the frame bound is refused once, its tail discarded to the newline, and the next line answered" "a line of exactly 1 MiB is admitted, one byte more is refused, and the message says exceeds" "a legacy Content-Length frame over the cap is refused by name and its declared body is drained, never read as the next frames" "a header line of a legacy Content-Length block is bounded like any line, and refused by name past it" "reading a line costs the loop about the line's bytes, not sixty times them" "a size refusal is -32600 on stdio as it is over HTTP, so one vector has one code" | LLM10:2025 Unbounded Consumption | -| **Deeply nested JSON** | BOUNDED, then REFUSED | The size cap bounds how deep a body can nest but not what decoding it costs: before this page, a 1 MiB body nested 524,288 levels deep was decoded in full — 79–96 ms and a **38 MiB heap** for one request, ~36× the body — and refused afterwards by its shape (measured 2026-09-16). Now `BeamMCP.JSON.decode/1` walks the bytes once before the decoder runs and refuses a body nesting past **64 levels** with `-32600` "Request body nests deeper than 64 levels" — `400` over HTTP with the connection kept (the body was read in full), the same error object on stdio — having built nothing: the worst body under the cap is refused in microseconds, and the handling process's heap never holds the nest. What the whole bounded decode costs against the decoder alone, by shape (medians of five, 2026-09-16): a 218-byte request 2 µs → 4 µs; a 1 MiB body that is one string 2.0 ms → 4.0 ms; a 600 KB array of digits 19 ms → 42 ms; an 878 KB object of 60,000 keys 21 ms → 45 ms; a 638 KB object nested four deep 17 ms → 44 ms — the key-dense shapes pay most, for the repeated-key check below; the worst nest 58 ms → refused in microseconds; the transient heap of a bounded decode is not above the decoder's own: on the 60,000-key object the sampled peaks (1 ms, medians of five, two lanes, 2026-09-16) are 10.7 MiB bounded against 11.5 MiB for `Jason.decode/1` alone — the time is the cost, not the memory (what a process holds after either returns depends on when it last collected, and is not stated). Sizes given in KB are decimal; MiB is binary throughout. Both transports read through the one function. The number is a constant, for the reason the body cap is one, and it is pinned by bytes — 64 admitted, 65 refused, as literals — and not by the constant it pins. | `test/beam_mcp/threat_model_test.exs` "over HTTP a body nested past the bound is refused by name, -32600 and 400, with the connection kept" "over HTTP a body nested exactly to the bound is not refused for its depth" "the worst body under the size cap is refused with a heap that never held the nest" "over stdio a line nested past the bound is refused by name, and the loop keeps going" "the bound is one number, read from one place, and it is the number the page states" "the number is sixty-four, pinned by bytes and not by the constant it pins" | LLM10:2025 | -| **A repeated key in the body** | REFUSED | Jason keeps the first of two equal keys; most other parsers keep the last. A hop in front of this server that routes on the last `"name"` while this server executes the first is two sources of truth inside one body — the disagreement the header–body match closes, reopened. Now a body that repeats a key in any object, at any depth, is `-32600` "Request body repeats a key: duplicate key \"name\"" (`400` over HTTP, the same object on stdio). The repeat is found in the decoded objects, not the bytes, so `"a"` and `"\u0061"` are one key as every decoder reads them; that is the cost in the row above. | `test/beam_mcp/threat_model_test.exs` "over HTTP a body with a repeated key is -32600 and 400, naming the key" "over stdio a line with a repeated key is -32600 naming the key, and the loop keeps going" "the decoder names the first repeated key at any depth, and admits equal keys in different objects" | ASI02 Tool Misuse (the wire half) | -| **Malformed JSON, a non-object, an empty body** | REFUSED | `-32700` for bytes that are not JSON or an empty body; `-32600` naming the type for JSON that is not an object — on both transports (until this page, stdio answered a string, a number or `null` with silence, and its parse error carried the decoder's inspected struct with the client's own bytes in it; now every refusal names its cause and carries no data); `400` over HTTP. The decoder is `Jason`, called on the bytes the client sent — a hook that verifies a signature over the body sees the client's bytes, since the transport reads them before anything decodes them. | `test/beam_mcp/transport/http_test.exs` "an empty body is a parse error, not an exception" "invalid JSON is a parse error"; `test/beam_mcp/transport/stdio_test.exs` "malformed JSON gets a parse error, and the loop keeps going"; `test/beam_mcp/threat_model_test.exs` "a line that is JSON but not an object is -32600 naming the type, never silence" "a parse error carries no inspected term and none of the client's bytes" | — | -| **Header injection; header–body disagreement** (the MCP-layer smuggling class) | REFUSED | Over HTTP every request carries `MCP-Protocol-Version`, `Mcp-Method`, and for the three methods that name a target `Mcp-Name`, plus any `Mcp-Param-{Name}` a tool's schema declares through `x-mcp-header`; each is held to the body and a disagreement, a missing header, or a second disagreeing value is `-32020` / `400`. A schema may annotate only primitive-typed properties; a tool whose schema annotates an object or a non-primitive is a host fault — every call on it is `500` / `-32603` until the schema is corrected, and the host's log names the header — while a caller's header that disagrees with the body is `-32020` naming the schema's header, never the caller's string. A request's `_meta` is read at `params._meta` only; the top level is refused with `-32602` naming the place, not accepted as a fallback. | `test/beam_mcp/transport/http_test.exs` "a header disagreeing with the body's _meta is a HeaderMismatch" "the header is matched to params._meta: a disagreement is -32020 with 400" "Mcp-Param-{Name} — a second, disagreeing value is not ignored" "a header the schema requires and the client omits is refused" "a refusal names the schema's header, never the caller's string" "an annotated `object` property is the same fault" "the refusal covers every header the transport reads, not just the version header" | ASI02 Tool Misuse (the wire half) | -| **Request smuggling at the HTTP layer** (`Content-Length` / `Transfer-Encoding` disagreement, pipelining) | DELEGATED | The HTTP server frames requests; this Plug reads one framed body through `Plug.Conn.read_body/2` and nothing else. What it adds: a refusal issued before the body is read closes the connection, so a refused caller's body is never read as the next request. The framing itself is `Bandit`'s (or the host's server's). | `test/beam_mcp/transport/http_test.exs` "a body over the cap is refused and the connection closed" | — | -| **DNS rebinding through `Origin`** | REFUSED | `allowed_origins:` is required at `init/1` — there is no default — and a disallowed or second disagreeing `Origin` is `403`, issued before the body is read. Binding the listener to localhost, which the specification says a local server SHOULD, is the HTTP server's option and the host's. | `test/beam_mcp/transport/http_test.exs` "init/1 raises without :allowed_origins" "a disallowed Origin gets 403" "Origin — a second, disallowed Origin is not ignored" | ASI03 Identity & Privilege Abuse | -| **Slow clients; drip bodies** | BOUNDED | `read_timeout:` on `BeamMCP.Transport.HTTP` is one whole-body deadline, this package's own — the body is read in pieces against one monotonic clock, each read given what remains — so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads. The adapter's own `:read_timeout` is not that: a per-read clock, so a cap-sized body (two adapter reads) got two deadlines, 1,909 ms for 1,000; and over HTTP/2 — which `Bandit` serves on the same plaintext listener by prior knowledge — its reader gathers DATA frames inside one read on a per-frame clock, and a one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline (both measured by review lanes, 2026-09-16; both closed — over HTTP/2 the reader is asked for less than one frame, so every DATA frame returns to this clock, an empty one included: under a length of zero a drip of empty frames had held the read, 2,321 ms for 300). Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the deadline. **One residue is the adapter's:** over HTTP/2 a stream kept open by control frames alone — a WINDOW_UPDATE, or a HEADERS without END_STREAM — re-arms the adapter's own wait for the whole of what the read gave it, so one such frame per deadline holds the stream indefinitely (under the default, one frame per fifteen seconds), and that wait is a receive on the adapter's messages that nothing outside it can end through an interface the adapter offers. Two levers exist and both are declined: forging the adapter's own message into the stream's mailbox would end it and couples this package to a private protocol a release of the adapter can change silently; ending the stream process gives the client no answer at all — the adapter forgets the stream and drops its frames. **The connection, though, is this package's to end.** `connection_timeout:` (a positive integer of milliseconds, default twice `read_timeout`) closes the whole connection with a `GOAWAY` the client can read when a body read has been held that long and nothing else on the connection is still within its own body deadline — an OTP `GenServer.stop` on the socket handler (found by the `handle_shutdown/2` callback it exports, not by an adapter name), which runs the handler's own orderly termination: `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. So the residue the adapter owns per stream is bounded in DURATION here: measured (2026-09-16), a stream pinned by WINDOW_UPDATE and one by HEADERS-without-END_STREAM, each fed a frame every 100 ms for three seconds under `connection_timeout: 600`, are closed with `GOAWAY(NO_ERROR)` at 607 and 603 ms — where without the bound (a high `connection_timeout`) each lived as long as the frames came, 3,341 and 3,333 ms. The cost is per connection and stated: the client's other streams still open on that connection end with the `GOAWAY`; a legitimate stream that already answered is unharmed (measured: on a shared connection the legitimate stream is answered `200`, then the held stream's `GOAWAY(NO_ERROR, last_stream_id 3)` at 602 ms), but a stream still in flight when the close fires dies with it, so a host multiplexing streams that outlive one body read raises `connection_timeout`. The deadline is twice the read deadline by default: one `read_timeout` for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the loop answers slow arrivals per DATA frame within `read_timeout`, so a read still blocked at twice it received only control frames). What such a stream costs the client and the host, by frame: a WINDOW_UPDATE is thirteen bytes, nothing accumulates, and nothing is logged (measured, 2026-09-16: twenty windows at 100 ms under a 300 ms deadline, the body at 2,114 ms, `408` at 2,124 ms); a HEADERS without END_STREAM — a malformed request under RFC 9113 §8.1, which the adapter reads as trailers and ignores — holds the stream the same way and **writes a warning line per frame to the host's log, `Ignoring trailers …`, carrying the client's header bytes** (twenty frames, twenty lines, `408` at 2,118 ms; a review lane, 2026-09-16), the one hold on this row that reaches the log. What it costs the host: one stream process held for as long as the frames keep coming, up to the adapter's streams-per-connection setting — `http_2_options: [default_local_settings: [max_concurrent_streams: n]]` on the `Bandit` listener, unlimited under its defaults — the connection-flood row's delegation, reached by a client that never sends a body byte. `http_2_options`'s `max_concurrent_streams` caps how many held streams one connection can pin (count), `connection_timeout` bounds how long each is held (duration), and `http_2_options: [enabled: false]` removes HTTP/2 from the listener and with it this residue whole — no MCP client this package has been run against speaks HTTP/2 by prior knowledge. Whatever body then comes is refused, since the clock is read on return. A change in the adapter — a deadline for its read rather than a per-frame wait — lifts the per-stream residue the package now bounds at the connection; the two per-stream tests named below fail the day the adapter honours the deadline it is given, and they carry a high `connection_timeout` so the package's connection bound does not close them first. The `408` is this package's JSON-RPC refusal, with `connection: close` over HTTP/1.1 (over HTTP/2 the stream ends with the response — the header would be a malformed one there, and every pre-body refusal that carried it was a stream reset to an HTTP/2 client until this page); nothing is written to the host's log for it — the adapter's own error-level line at its read timeout no longer fires, since the deadline is this package's. The default is 15,000 ms, a chosen number with its reasoning beside the constant: a cap-sized body needs 68.3 KiB/s (~559 kbit/s) sustained to arrive under it — arithmetic, since every measurement this package has is on loopback — and a drip attack costs the host at most the body cap per connection for fifteen seconds (a legitimate request served in under 0.01 s with 12,000 drip connections opened against it, 2026-09-07 — cumulative opens, not a steady state). It is a DoS control and the host's: longer behind a slow link, shorter facing the open internet. For two releases this package passed none and the value in force was `Bandit`'s default for such a call — not a server option, and not a choice. | `test/beam_mcp/transport/http_bandit_test.exs` "a drip client is answered 408 at the deadline set, at two values" "a body over the adapter's read length and under the cap is one deadline, not two" "over HTTP/2 a drip of one byte per frame is still answered at the deadline, and the refusal is readable" "over HTTP/2 an empty DATA frame returns to the deadline's clock like any other" "over HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the adapter, and nothing late is served" "over HTTP/2 a stream kept open by HEADERS frames without END_STREAM is held the same way, each frame a warning line in the host's log" "over HTTP/2 a connection pinned by WINDOW_UPDATE frames alone is closed with a readable GOAWAY at the connection deadline" "over HTTP/2 a connection pinned by HEADERS frames without END_STREAM is closed the same way" "over HTTP/2 a legitimate stream on the pinned connection is served before the close, and the close is a GOAWAY not a reset" "init/1 takes connection_timeout: and refuses a value that is not a positive integer" "over HTTP/2 a whole body under the deadline is served, and a refusal before the body is a response, not a stream reset" "init/1 takes read_timeout: and refuses a value that is not a positive integer" "the default is the stated one, and a drip client under it is not answered inside a second" | LLM10:2025 | -| **A body that does not declare its length** (`transfer-encoding: chunked`) | REFUSED | `411` before the body is read, with `connection: close`. An MCP request is one complete JSON message under a 1 MiB cap and a chunked coding buys it nothing, while it defeats every whole-body bound: the adapter reads a chunked body chunk by chunk, each chunk on its own clock, gathering chunks until the length asked for is filled — a client sending one byte per chunk was served after 43 s under a 15 s deadline (a review lane, 2026-09-16). | `test/beam_mcp/transport/http_bandit_test.exs` "a chunked body is refused with 411 before it is read, so no drip can reset the deadline chunk by chunk" | LLM10:2025 | -| **Connection floods; too many in flight** | DELEGATED | Each in-flight request at the body cap costs about 1.05 MiB, linear to 8,000 concurrent with no plateau (measured 2026-09-07); the ceiling on how many is `num_acceptors × num_connections` of the server (100 × 16,384 under `Bandit`'s defaults), the host's capacity decision. The nesting bound above is what keeps that per-request figure at the body's size rather than thirty-six times it. | — (a server setting) | LLM10:2025 | -| **Header count and size; TLS** | DELEGATED | Both the HTTP server's. This Plug speaks plaintext to the server that terminates TLS for it; a deployment that needs TLS configures it there. | — | — | -| **Unsafe deserialization; code from input** | REFUSED by construction | Input is decoded by `Jason` to strings, numbers, lists and maps and nothing else: no `binary_to_term`, no evaluator, no module or function built from a name in the input, no atom created from a caller's key — 10,000 distinct keys through `tools/call` and `prompts/get` leave the atom table where it was. The `:xref` census over the built beams pins every module the package calls and, on the modules through which code, secrets, the OS or another node could be reached, every function. | `test/beam_mcp/argument_interning_test.exs` "10,000 distinct caller keys through prompts/get and tools/call leave the atom table where it was"; `test/beam_mcp/boundary/no_dynamic_evaluation_test.exs` "no line under lib/ evaluates code or builds a name at runtime, beyond the argument-key atoms"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" "on the modules that could reach code, names, secrets, the OS or another node, the functions called are exactly the listed ones" | ASI05 Unexpected Code Execution | -| **Tool arguments the schema does not admit** | REFUSED | `tools/call` validates arguments against the schema `tools/list` advertised — the same struct, one lookup, so the two cannot disagree — and a missing required property or a forbidden one is refused before dispatch — as a tool error result (`200`, `isError: true`, the text naming the property), which is the shape the specification gives a call the tool could not take, not a JSON-RPC error. What a *valid* call does is the host's tool. | `test/beam_mcp/tool_spec_schema_test.exs` "a call missing a required property the catalog declared is refused" "a call carrying a property the catalog's schema forbids is refused" "tools/list advertises the schema the catalog carries" | ASI02 Tool Misuse (the wire half) | +| **Oversized body** | BOUNDED, then REFUSED | Each transport caps one body at 1 MiB: exactly 1,048,576 bytes admitted, the byte past it refused, on the HTTP body, the stdio line and the legacy `Content-Length` frame alike (the stdio line counts a trailing `\r` before trimming it, so a CRLF client has one byte less of payload; MCP's framing is LF). Over HTTP/1 the server reads exactly the cap (1,048,576 bytes, constant across six socket-buffer settings, measured 2026-09-07) and answers `413` / `-32600` with `connection: close`, so the HTTP server does not drain the rest on behalf of a caller already refused; over HTTP/2 the adapter hands whole frames, so the read is the cap plus the frame that crosses it (at most 16 KiB, the adapter's frame size; 1,048,576 + 16,384 measured by a review lane, 2026-09-16), the `413` is answered at that frame, not at the stream's end or the deadline, and it carries no `connection: close` (the stream ends with the response) (a mutant that gave the size rule a piece of slack held a 64 KiB overrun and answered `408` instead; the pin is HTTP/2's alone, since over HTTP/1 a read never asks past the cap). Over stdio a line past the frame bound is refused as it is read (`-32600` "Request line exceeds 1048576 bytes", the code the HTTP refusal carries, so one vector has one code), the line held as one off-heap binary that the loop retains nothing of (a list of one-byte binaries had cost 46–67 MiB of heap per 1 MiB line, and the legacy `Content-Length` check downcased the whole line for a fifteen-byte prefix, 40 MiB more; 0 MiB sampled at 1 ms during a 1 MiB read now, 2026-09-16), never buffered past the bound, and the rest of the line is drained to its newline so no tail of it is read as the next frame (until this page, the tail was); a legacy `Content-Length` frame declaring more than the cap is refused by name (`-32600` "Request frame exceeds 1048576 bytes") and its declared body drained in chunks, never buffered; every header line of that block is read under the same line bound, and past it the block and its body are drained (a lane sent 64 MiB on one header line and it was read whole); until this page the body was left on the pipe and a request inside it was dispatched (a lane put one there and watched it answer). | `test/beam_mcp/transport/http_test.exs` "a body over the cap is refused and the connection closed"; `test/beam_mcp/transport/http_bandit_test.exs` "over HTTP/2 a body is refused at the frame that crosses the cap, not at the stream's end"; `test/beam_mcp/readme_claims_test.exs` "the 1 MiB cap is the number the code enforces" "the server-side read before a refusal is the cap itself, not a range"; `test/beam_mcp/transport/stdio_test.exs` "a line beyond the frame bound is refused rather than buffered"; `test/beam_mcp/threat_model_test.exs` "a line past the frame bound is refused once, its tail discarded to the newline, and the next line answered" "a line of exactly 1 MiB is admitted, one byte more is refused, and the message says exceeds" "a legacy Content-Length frame over the cap is refused by name and its declared body is drained, never read as the next frames" "a header line of a legacy Content-Length block is bounded like any line, and refused by name past it" "reading a line costs the loop about the line's bytes, not sixty times them" "a size refusal is -32600 on stdio as it is over HTTP, so one vector has one code" | LLM10:2025 Unbounded Consumption | +| **Deeply nested JSON** | BOUNDED, then REFUSED | The size cap bounds how deep a body can nest but not what decoding it costs: before this page, a 1 MiB body nested 524,288 levels deep was decoded in full (79–96 ms and a **38 MiB heap** for one request, ~36× the body) and refused afterwards by its shape (measured 2026-09-16). Now `BeamMCP.JSON.decode/1` walks the bytes once before the decoder runs and refuses a body nesting past **64 levels** with `-32600` "Request body nests deeper than 64 levels", `400` over HTTP with the connection kept (the body was read in full), the same error object on stdio, having built nothing: the worst body under the cap is refused in microseconds, and the handling process's heap never holds the nest. What the whole bounded decode costs against the decoder alone, by shape (medians of five, 2026-09-16): a 218-byte request 2 µs → 4 µs; a 1 MiB body that is one string 2.0 ms → 4.0 ms; a 600 KB array of digits 19 ms → 42 ms; an 878 KB object of 60,000 keys 21 ms → 45 ms; a 638 KB object nested four deep 17 ms → 44 ms (the key-dense shapes pay most, for the repeated-key check below); the worst nest 58 ms → refused in microseconds; the transient heap of a bounded decode is not above the decoder's own: on the 60,000-key object the sampled peaks (1 ms, medians of five, two lanes, 2026-09-16) are 10.7 MiB bounded against 11.5 MiB for `Jason.decode/1` alone; the time is the cost, not the memory (what a process holds after either returns depends on when it last collected, and is not stated). Sizes given in KB are decimal; MiB is binary throughout. Both transports read through the one function. The number is a constant, for the reason the body cap is one, and it is pinned by bytes (64 admitted, 65 refused, as literals) and not by the constant it pins. | `test/beam_mcp/threat_model_test.exs` "over HTTP a body nested past the bound is refused by name, -32600 and 400, with the connection kept" "over HTTP a body nested exactly to the bound is not refused for its depth" "the worst body under the size cap is refused with a heap that never held the nest" "over stdio a line nested past the bound is refused by name, and the loop keeps going" "the bound is one number, read from one place, and it is the number the page states" "the number is sixty-four, pinned by bytes and not by the constant it pins" | LLM10:2025 | +| **A repeated key in the body** | REFUSED | Jason keeps the first of two equal keys; most other parsers keep the last. A hop in front of this server that routes on the last `"name"` while this server executes the first is two sources of truth inside one body: the disagreement the header–body match closes, reopened. Now a body that repeats a key in any object, at any depth, is `-32600` "Request body repeats a key: duplicate key \"name\"" (`400` over HTTP, the same object on stdio). The repeat is found in the decoded objects, not the bytes, so `"a"` and `"\u0061"` are one key as every decoder reads them; that is the cost in the row above. | `test/beam_mcp/threat_model_test.exs` "over HTTP a body with a repeated key is -32600 and 400, naming the key" "over stdio a line with a repeated key is -32600 naming the key, and the loop keeps going" "the decoder names the first repeated key at any depth, and admits equal keys in different objects" | ASI02 Tool Misuse (the wire half) | +| **Malformed JSON, a non-object, an empty body** | REFUSED | `-32700` for bytes that are not JSON or an empty body; `-32600` naming the type for JSON that is not an object, on both transports (until this page, stdio answered a string, a number or `null` with silence, and its parse error carried the decoder's inspected struct with the client's own bytes in it; now every refusal names its cause and carries no data); `400` over HTTP. The decoder is `Jason`, called on the bytes the client sent: a hook that verifies a signature over the body sees the client's bytes, since the transport reads them before anything decodes them. | `test/beam_mcp/transport/http_test.exs` "an empty body is a parse error, not an exception" "invalid JSON is a parse error"; `test/beam_mcp/transport/stdio_test.exs` "malformed JSON gets a parse error, and the loop keeps going"; `test/beam_mcp/threat_model_test.exs` "a line that is JSON but not an object is -32600 naming the type, never silence" "a parse error carries no inspected term and none of the client's bytes" | none | +| **Header injection; header–body disagreement** (the MCP-layer smuggling class) | REFUSED | Over HTTP every request carries `MCP-Protocol-Version`, `Mcp-Method`, and for the three methods that name a target `Mcp-Name`, plus any `Mcp-Param-{Name}` a tool's schema declares through `x-mcp-header`; each is held to the body and a disagreement, a missing header, or a second disagreeing value is `-32020` / `400`. A schema may annotate only primitive-typed properties; a tool whose schema annotates an object or a non-primitive is a host fault (every call on it is `500` / `-32603` until the schema is corrected, and the host's log names the header), while a caller's header that disagrees with the body is `-32020` naming the schema's header, never the caller's string. A request's `_meta` is read at `params._meta` only; the top level is refused with `-32602` naming the place, not accepted as a fallback. | `test/beam_mcp/transport/http_test.exs` "a header disagreeing with the body's _meta is a HeaderMismatch" "the header is matched to params._meta: a disagreement is -32020 with 400" "Mcp-Param-{Name}: a second, disagreeing value is not ignored" "a header the schema requires and the client omits is refused" "a refusal names the schema's header, never the caller's string" "an annotated `object` property is the same fault" "the refusal covers every header the transport reads, not just the version header" | ASI02 Tool Misuse (the wire half) | +| **Request smuggling at the HTTP layer** (`Content-Length` / `Transfer-Encoding` disagreement, pipelining) | DELEGATED | The HTTP server frames requests; this Plug reads one framed body through `Plug.Conn.read_body/2` and nothing else. What it adds: a refusal issued before the body is read closes the connection, so a refused caller's body is never read as the next request. The framing itself is `Bandit`'s (or the host's server's). | `test/beam_mcp/transport/http_test.exs` "a body over the cap is refused and the connection closed" | none | +| **DNS rebinding through `Origin`** | REFUSED | `allowed_origins:` is required at `init/1` (there is no default), and a disallowed or second disagreeing `Origin` is `403`, issued before the body is read. Binding the listener to localhost, which the specification says a local server SHOULD, is the HTTP server's option and the host's. | `test/beam_mcp/transport/http_test.exs` "init/1 raises without :allowed_origins" "a disallowed Origin gets 403" "Origin: a second, disallowed Origin is not ignored" | ASI03 Identity & Privilege Abuse | +| **Slow clients; drip bodies** | BOUNDED | `read_timeout:` on `BeamMCP.Transport.HTTP` is one whole-body deadline, this package's own (the body is read in pieces against one monotonic clock, each read given what remains), so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads. The adapter's own `:read_timeout` is not that: a per-read clock, so a cap-sized body (two adapter reads) got two deadlines, 1,909 ms for 1,000; and over HTTP/2, which `Bandit` serves on the same plaintext listener by prior knowledge, its reader gathers DATA frames inside one read on a per-frame clock, and a one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline (both measured by review lanes, 2026-09-16; both closed: over HTTP/2 the reader is asked for less than one frame, so every DATA frame returns to this clock, an empty one included: under a length of zero a drip of empty frames had held the read, 2,321 ms for 300). Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the deadline. **One residue is the adapter's:** over HTTP/2 a stream kept open by control frames alone (a WINDOW_UPDATE, or a HEADERS without END_STREAM) re-arms the adapter's own wait for the whole of what the read gave it, so one such frame per deadline holds the stream indefinitely (under the default, one frame per fifteen seconds), and that wait is a receive on the adapter's messages that nothing outside it can end through an interface the adapter offers. Two levers exist and both are declined: forging the adapter's own message into the stream's mailbox would end it and couples this package to a private protocol a release of the adapter can change silently; ending the stream process gives the client no answer at all: the adapter forgets the stream and drops its frames. **The connection, though, is this package's to end.** `connection_timeout:` (a positive integer of milliseconds, default twice `read_timeout`) closes the whole connection with a `GOAWAY` the client can read when a body read has been held that long and nothing else on the connection is still within its own body deadline: an OTP `GenServer.stop` on the socket handler (found by the `handle_shutdown/2` callback it exports, not by an adapter name), which runs the handler's own orderly termination: `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. So the residue the adapter owns per stream is bounded in DURATION here: measured (2026-09-16), a stream pinned by WINDOW_UPDATE and one by HEADERS-without-END_STREAM, each fed a frame every 100 ms for three seconds under `connection_timeout: 600`, are closed with `GOAWAY(NO_ERROR)` at 607 and 603 ms, where without the bound (a high `connection_timeout`) each lived as long as the frames came, 3,341 and 3,333 ms. The cost is per connection and stated: the client's other streams still open on that connection end with the `GOAWAY`; a legitimate stream that already answered is unharmed (measured: on a shared connection the legitimate stream is answered `200`, then the held stream's `GOAWAY(NO_ERROR, last_stream_id 3)` at 602 ms), but a stream still in flight when the close fires dies with it, so a host multiplexing streams that outlive one body read raises `connection_timeout`. The deadline is twice the read deadline by default: one `read_timeout` for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the loop answers slow arrivals per DATA frame within `read_timeout`, so a read still blocked at twice it received only control frames). What such a stream costs the client and the host, by frame: a WINDOW_UPDATE is thirteen bytes, nothing accumulates, and nothing is logged (measured, 2026-09-16: twenty windows at 100 ms under a 300 ms deadline, the body at 2,114 ms, `408` at 2,124 ms); a HEADERS without END_STREAM (a malformed request under RFC 9113 §8.1, which the adapter reads as trailers and ignores) holds the stream the same way and **writes a warning line per frame to the host's log, `Ignoring trailers …`, carrying the client's header bytes** (twenty frames, twenty lines, `408` at 2,118 ms; a review lane, 2026-09-16), the one hold on this row that reaches the log. What it costs the host: one stream process held for as long as the frames keep coming, up to the adapter's streams-per-connection setting (`http_2_options: [default_local_settings: [max_concurrent_streams: n]]` on the `Bandit` listener, unlimited under its defaults), the connection-flood row's delegation, reached by a client that never sends a body byte. `http_2_options`'s `max_concurrent_streams` caps how many held streams one connection can pin (count), `connection_timeout` bounds how long each is held (duration), and `http_2_options: [enabled: false]` removes HTTP/2 from the listener and with it this residue whole; no MCP client this package has been run against speaks HTTP/2 by prior knowledge. Whatever body then comes is refused, since the clock is read on return. A change in the adapter (a deadline for its read rather than a per-frame wait) lifts the per-stream residue the package now bounds at the connection; the two per-stream tests named below fail the day the adapter honours the deadline it is given, and they carry a high `connection_timeout` so the package's connection bound does not close them first. The `408` is this package's JSON-RPC refusal, with `connection: close` over HTTP/1.1 (over HTTP/2 the stream ends with the response; the header would be a malformed one there, and every pre-body refusal that carried it was a stream reset to an HTTP/2 client until this page); nothing is written to the host's log for it: the adapter's own error-level line at its read timeout no longer fires, since the deadline is this package's. The default is 15,000 ms, a chosen number with its reasoning beside the constant: a cap-sized body needs 68.3 KiB/s (~559 kbit/s) sustained to arrive under it (arithmetic, since every measurement this package has is on loopback), and a drip attack costs the host at most the body cap per connection for fifteen seconds (a legitimate request served in under 0.01 s with 12,000 drip connections opened against it, 2026-09-07; cumulative opens, not a steady state). It is a DoS control and the host's: longer behind a slow link, shorter facing the open internet. For two releases this package passed none and the value in force was `Bandit`'s default for such a call, not a server option, and not a choice. | `test/beam_mcp/transport/http_bandit_test.exs` "a drip client is answered 408 at the deadline set, at two values" "a body over the adapter's read length and under the cap is one deadline, not two" "over HTTP/2 a drip of one byte per frame is still answered at the deadline, and the refusal is readable" "over HTTP/2 an empty DATA frame returns to the deadline's clock like any other" "over HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the adapter, and nothing late is served" "over HTTP/2 a stream kept open by HEADERS frames without END_STREAM is held the same way, each frame a warning line in the host's log" "over HTTP/2 a connection pinned by WINDOW_UPDATE frames alone is closed with a readable GOAWAY at the connection deadline" "over HTTP/2 a connection pinned by HEADERS frames without END_STREAM is closed the same way" "over HTTP/2 a legitimate stream on the pinned connection is served before the close, and the close is a GOAWAY not a reset" "init/1 takes connection_timeout: and refuses a value that is not a positive integer" "over HTTP/2 a whole body under the deadline is served, and a refusal before the body is a response, not a stream reset" "init/1 takes read_timeout: and refuses a value that is not a positive integer" "the default is the stated one, and a drip client under it is not answered inside a second" | LLM10:2025 | +| **A body that does not declare its length** (`transfer-encoding: chunked`) | REFUSED | `411` before the body is read, with `connection: close`. An MCP request is one complete JSON message under a 1 MiB cap and a chunked coding buys it nothing, while it defeats every whole-body bound: the adapter reads a chunked body chunk by chunk, each chunk on its own clock, gathering chunks until the length asked for is filled; a client sending one byte per chunk was served after 43 s under a 15 s deadline (a review lane, 2026-09-16). | `test/beam_mcp/transport/http_bandit_test.exs` "a chunked body is refused with 411 before it is read, so no drip can reset the deadline chunk by chunk" | LLM10:2025 | +| **Connection floods; too many in flight** | DELEGATED | Each in-flight request at the body cap costs about 1.05 MiB, linear to 8,000 concurrent with no plateau (measured 2026-09-07); the ceiling on how many is `num_acceptors × num_connections` of the server (100 × 16,384 under `Bandit`'s defaults), the host's capacity decision. The nesting bound above is what keeps that per-request figure at the body's size rather than thirty-six times it. | none (a server setting) | LLM10:2025 | +| **Header count and size; TLS** | DELEGATED | Both the HTTP server's. This Plug speaks plaintext to the server that terminates TLS for it; a deployment that needs TLS configures it there. | none | none | +| **Unsafe deserialization; code from input** | REFUSED by construction | Input is decoded by `Jason` to strings, numbers, lists and maps and nothing else: no `binary_to_term`, no evaluator, no module or function built from a name in the input, no atom created from a caller's key; 10,000 distinct keys through `tools/call` and `prompts/get` leave the atom table where it was. The `:xref` census over the built beams pins every module the package calls and, on the modules through which code, secrets, the OS or another node could be reached, every function. | `test/beam_mcp/argument_interning_test.exs` "10,000 distinct caller keys through prompts/get and tools/call leave the atom table where it was"; `test/beam_mcp/boundary/no_dynamic_evaluation_test.exs` "no line under lib/ evaluates code or builds a name at runtime, beyond the argument-key atoms"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" "on the modules that could reach code, names, secrets, the OS or another node, the functions called are exactly the listed ones" | ASI05 Unexpected Code Execution | +| **Tool arguments the schema does not admit** | REFUSED | `tools/call` validates arguments against the schema `tools/list` advertised (the same struct, one lookup, so the two cannot disagree), and a missing required property or a forbidden one is refused before dispatch, as a tool error result (`200`, `isError: true`, the text naming the property), which is the shape the specification gives a call the tool could not take, not a JSON-RPC error. What a *valid* call does is the host's tool. | `test/beam_mcp/tool_spec_schema_test.exs` "a call missing a required property the catalog declared is refused" "a call carrying a property the catalog's schema forbids is refused" "tools/list advertises the schema the catalog carries" | ASI02 Tool Misuse (the wire half) | | **A session or identity to steal or replay** | none exists | No session identifier is issued, honoured or read; each request stands alone; identity is the host's `authorize/1`, and this package holds no key and makes no signature of its own (a host-supplied signer signs the canonical bytes through one pinned callback; the key stays with it), so there is none to steal from it. | `test/beam_mcp/boundary/no_session_test.exs` "no response carries an mcp-session-id header, and a request carrying one is answered as if it did not"; `test/beam_mcp/boundary/no_key_holding_test.exs` "no line under lib/ names key material or calls a crypto function other than :crypto.hash/2"; `test/beam_mcp/boundary/no_signature_test.exs` "no line under lib/ calls a signing or MAC primitive" | ASI03 | -| **What a refusal or a fault leaks** | BOUNDED | An error carries structured fields, never an inspected Elixir term; a host's error term goes out as JSON; a host fault (raise, throw, exit in dispatch, a hook, or the catalog) is answered `-32603` with the id and no stacktrace — `500` over HTTP; on stdio the same object and the loop goes on (until this page a fault in the host's dispatch ended the stdio loop with nothing written). The `:telemetry` exception event and the transport's log carry a stacktrace of **arities**, never arguments. | `test/beam_mcp/error_payload_test.exs` "a validation failure carries structured fields, not an inspected map" "the human-readable content carries no Elixir syntax either"; `test/beam_mcp/transport/http_test.exs` "throw and exit are answered, not left as an empty 500" "a host authorize/1 that raises, throws or exits is answered, not left as a bare 500"; `test/beam_mcp/threat_model_test.exs` "a host dispatch that raises, throws or exits is answered -32603 with the id, and the loop keeps going"; `test/beam_mcp/connectome/observed_test.exs` "the :exception stacktrace carries arities, never arguments: a function_clause or a BIF error would have put the call's arguments in its top frame" | LLM02:2025 Sensitive Information Disclosure | -| **A payload byte in the observed graph** | REFUSED by construction | The observed graph carries edge identity only — caller, callee, kind, a count — never an argument, a result or a message term; the tracer traces with the `:arity` flag and never reads a message. | `test/beam_mcp/readme_claims_test.exs` "the observed graph carries edge identity only, never a payload byte"; `test/beam_mcp/connectome/tracer_test.exs` "a traced call is a module-level :invoke edge from the caller's module to the callee's, and nothing of the arguments" | LLM02:2025 | -| **The tracer's own cost** (an opt-in, off by default) | BOUNDED | `max_messages` and `max_duration_ms` are required and finite; the count destroys the tracer's trace session — every pattern and flag it set — at the limit; a companion process enforces the deadline from outside the tracer's mailbox. The mailbox bound between a call storm and the clear is physics and is stated with its numbers on `docs/connectome-observed.md`. | `test/beam_mcp/connectome/tracer_test.exs` "a limit is required to be positive and finite; there is no unbounded mode" "at max_messages: the count is reached, tracing is off, every pattern is cleared, nothing left behind" "the duration limit stops tracing on time even when the tracer is not being scheduled, and the tracer leaves on the next message rather than draining the queue" | LLM10:2025 | +| **What a refusal or a fault leaks** | BOUNDED | An error carries structured fields, never an inspected Elixir term; a host's error term goes out as JSON; a host fault (raise, throw, exit in dispatch, a hook, or the catalog) is answered `-32603` with the id and no stacktrace, `500` over HTTP; on stdio the same object and the loop goes on (until this page a fault in the host's dispatch ended the stdio loop with nothing written). The `:telemetry` exception event and the transport's log carry a stacktrace of **arities**, never arguments. | `test/beam_mcp/error_payload_test.exs` "a validation failure carries structured fields, not an inspected map" "the human-readable content carries no Elixir syntax either"; `test/beam_mcp/transport/http_test.exs` "throw and exit are answered, not left as an empty 500" "a host authorize/1 that raises, throws or exits is answered, not left as a bare 500"; `test/beam_mcp/threat_model_test.exs` "a host dispatch that raises, throws or exits is answered -32603 with the id, and the loop keeps going"; `test/beam_mcp/connectome/observed_test.exs` "the :exception stacktrace carries arities, never arguments: a function_clause or a BIF error would have put the call's arguments in its top frame" | LLM02:2025 Sensitive Information Disclosure | +| **A payload byte in the observed graph** | REFUSED by construction | The observed graph carries edge identity only (caller, callee, kind, a count), never an argument, a result or a message term; the tracer traces with the `:arity` flag and never reads a message. | `test/beam_mcp/readme_claims_test.exs` "the observed graph carries edge identity only, never a payload byte"; `test/beam_mcp/connectome/tracer_test.exs` "a traced call is a module-level :invoke edge from the caller's module to the callee's, and nothing of the arguments" | LLM02:2025 | +| **The tracer's own cost** (an opt-in, off by default) | BOUNDED | `max_messages` and `max_duration_ms` are required and finite; the count destroys the tracer's trace session (every pattern and flag it set) at the limit; a companion process enforces the deadline from outside the tracer's mailbox. The mailbox bound between a call storm and the clear is physics and is stated with its numbers on `docs/connectome-observed.md`. | `test/beam_mcp/connectome/tracer_test.exs` "a limit is required to be positive and finite; there is no unbounded mode" "at max_messages: the count is reached, tracing is off, every pattern is cleared, nothing left behind" "the duration limit stops tracing on time even when the tracer is not being scheduled, and the tracer leaves on the next message rather than draining the queue" | LLM10:2025 | | **A multi-round-trip request carrying server state back** | none exists | Every request is answered completely or refused; `inputResponses` and `requestState` are read nowhere, so there is no server state for a client to forge. | `test/beam_mcp/boundary/no_mrtr_test.exs` "no line under lib/ reads inputResponses or requestState, and none names InputRequiredResult" | ASI06 Memory & Context Poisoning | -| **A capability, a client, an OAuth flow that is not there** | none exists | No capability the specification does not define is advertised; no module is a client; no outbound connection is opened; no OAuth. What is not built cannot be exploited, and a census holds each absence. | `test/beam_mcp/boundary/no_invented_capability_test.exs` "server/discover advertises only keys the 2026-07-28 schema defines" "the initialize result advertises only keys the 2025-11-25 schema defines"; `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no OAuth under lib/" "no client under lib/: no client module, no outbound connection, initialize only ever received" | — (an absence; nothing to map) | -| **The supply chain** | stated, not yet audited | Four dependencies, two optional (`plug` and `bandit` are the HTTP transport's; a stdio-only host carries neither); `plug_crypto` arrives through `plug` and is barred by name from `lib/`. A software bill of materials and a provenance statement are scheduled slices, not shipped; until they are, `mix.lock` is the list. | `test/beam_mcp/boundary/no_key_holding_test.exs` "no line under lib/ names key material or calls a crypto function other than :crypto.hash/2" | LLM03:2025 Supply Chain; ASI04 | +| **A capability, a client, an OAuth flow that is not there** | none exists | No capability the specification does not define is advertised; no module is a client; no outbound connection is opened; no OAuth. What is not built cannot be exploited, and a census holds each absence. | `test/beam_mcp/boundary/no_invented_capability_test.exs` "server/discover advertises only keys the 2026-07-28 schema defines" "the initialize result advertises only keys the 2025-11-25 schema defines"; `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no OAuth under lib/" "no client under lib/: no client module, no outbound connection, initialize only ever received" | none (an absence; nothing to map) | +| **The supply chain** | stated, not yet audited | Four dependencies, two optional (`plug` and `bandit` are the HTTP transport's; a stdio-only host carries neither); `plug_crypto` arrives through `plug` and is barred by name from `lib/`. Each release tarball carries a build-provenance attestation from `0.6.0` and, from `0.10.0`, an attested CycloneDX SBOM of the runtime dependency set (`docs/provenance.md`); `mix.lock` is the list in the tree, and `mix hex.audit` reads it on every push. | `test/beam_mcp/boundary/no_key_holding_test.exs` "no line under lib/ names key material or calls a crypto function other than :crypto.hash/2" | LLM03:2025 Supply Chain; ASI04 | ## What is out of scope, and why @@ -76,8 +76,8 @@ reading of each entry's title, not a claim of coverage. name, `:erlang.trace` any process, call the host's dispatch function directly, or replace a module with `:code.load_binary/3`. The BEAM has no in-node isolation, and nothing this package can do makes it a boundary against that; a reader who takes it for one is in more danger than -one who knows it is not. The shape guard on the tracer's claim is robustness — it keeps -`stop/0` total against a claim of the wrong shape — not defence. This is the tracer's paragraph, +one who knows it is not. The shape guard on the tracer's claim is robustness (it keeps +`stop/0` total against a claim of the wrong shape), not defence. This is the tracer's paragraph, package-wide: it holds for the collector's table, the tracer's claim, and every module here. A node-level boundary is the host's: separate nodes, or a release that runs no untrusted code. @@ -88,7 +88,7 @@ carried by this package as the host's bytes and never read. The host that chose tool, and the client that chose to feed its result to a model, own that vector between them. **What a valid tool call does.** A call the schema admits reaches the host's dispatch; what -the tool then does — the effect in the world, the privilege it uses — is the host's, and the +the tool then does (the effect in the world, the privilege it uses) is the host's, and the connectome exists to make that inspectable (which effects a catalog can reach, and which paths cross a gate), not to decide it. The sign an edge carries is a consumer's; this package writes only `:unset` and never treats any sign as suppression. @@ -116,8 +116,8 @@ so the seam is designed against it rather than discovered by it: already names, and the merged bytes must say which authority wrote which. - **Integrity in transit.** This package makes no signature of its own and holds no key; a graph that crosses a node boundary is verified, if at all, by the host's signer over the - canonical bytes — through `BeamMCP.Connectome.Canonical.signature/3` and a module - implementing `BeamMCP.Signer`, or by the host's own call over `encode/2`'s bytes — whose + canonical bytes (through `BeamMCP.Connectome.Canonical.signature/3` and a module + implementing `BeamMCP.Signer`, or by the host's own call over `encode/2`'s bytes), whose hash this package computes and never signs itself. Which key registry verifies a sub-graph is the open question the federation seam is held on. - **Trust in the peer.** A peer node is, for the model above, the same as a client on the @@ -148,14 +148,14 @@ this one; the cap and the read deadline on this route are the package's (the slo Applications for 2026* (published 2025-12-09), read the same day from its announcement. - **What a census does not prove** is stated on `docs/will-not-implement.md` and holds here: a barred act under a name no pattern lists, compile-time code, a dependency's own body. - The citation census asks ExUnit which cited tests are live — the module's own test list + The citation census asks ExUnit which cited tests are live (the module's own test list and tags, where every spelling of `@tag`, `@describetag` and `@moduletag` ends up, and where - a commented-out test or a `test "…"` inside a string is not — rather than reading the text; + a commented-out test or a `test "…"` inside a string is not) rather than reading the text; an `--exclude` at the runner (the suite excludes `:hot_reload` under coverage) it does not see. ## Related -`docs/will-not-implement.md` — what the package will never do, entry by entry, with the test. -`docs/connectome-observed.md` — the tracer's model this one extends, with its measured bounds. -The README's "Resources this Plug bounds, and the ones it does not" — the body cap's three +`docs/will-not-implement.md`: what the package will never do, entry by entry, with the test. +`docs/connectome-observed.md`: the tracer's model this one extends, with its measured bounds. +The README's "Resources this Plug bounds, and the ones it does not": the body cap's three non-properties, measured. diff --git a/docs/will-not-implement.md b/docs/will-not-implement.md index cb1bbf24..331c0e32 100644 --- a/docs/will-not-implement.md +++ b/docs/will-not-implement.md @@ -8,11 +8,11 @@ SPDX-License-Identifier: Apache-2.0 This page is the package's boundary, written down so that it survives the issue tracker. Each entry below is something `beam_mcp` will never do, with the reason in a line and the test that enforces it, by path and by name. **The tests are the proof; this page is the contract.** A -request to cross one of these lines is a request for a different package: the *host* — the +request to cross one of these lines is a request for a different package: the *host* (the application that embeds this package, supplies its catalog and dispatch function, and owns -every decision about them — for entries 1, 4, 7, 11 and 12; a *signer* — a module behind the -one callback `BeamMCP.Signer` names, the reference one in the separate package -`beam_mcp_signer` (Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`) — +every decision about them) for entries 1, 4, 7, 11 and 12; a *signer* (a module behind the +one callback `BeamMCP.Signer` names; the reference one is in the separate package +`beam_mcp_signer`, Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`) for entries 2 and 3; a client, a logger, an authorization server or a graph-mining library that this project will not write, for entries 9, 5, 8 and 10; and for entry 6, the specification itself, since only it can @@ -26,13 +26,13 @@ Neither may drift from the other. ## How the censuses read the tree A **census** is a test over the source rather than over behaviour. The censuses under -`test/beam_mcp/boundary/` — entries 2, 3, 4, 6, 7, 8, 9, 10, 11 and 12 — share one reader +`test/beam_mcp/boundary/` (entries 2, 3, 4, 6, 7, 8, 9, 10, 11 and 12) share one reader (`test/support/beam_mcp/boundary.ex`): every non-comment line of every `lib/**/*.ex` file, the same files `mix compile` reads, so an untracked module is seen; doc strings are read too, so a census that must allow prose says so by pattern. That `lib/**/*.ex` is the whole application is -itself pinned, in the source and in the built artefact — no Erlang sources, no other compile path, Mix's own compilers and no other, no macro, `quote` or compile-time read under `lib/`, and every module the built application lists a `BeamMCP.` one, the `.app` list agreeing with the ebin: `test/beam_mcp/boundary/population_test.exs` "nothing compiles into the application from outside lib/: no Erlang sources, no other elixirc path" "every module the built application lists is a BeamMCP module, and the list is the ebin". +itself pinned, in the source and in the built artefact (no Erlang sources, no other compile path, Mix's own compilers and no other, no macro, `quote` or compile-time read under `lib/`, and every module the built application lists a `BeamMCP.` one, the `.app` list agreeing with the ebin): `test/beam_mcp/boundary/population_test.exs` "nothing compiles into the application from outside lib/: no Erlang sources, no other elixirc path" "every module the built application lists is a BeamMCP module, and the list is the ebin". Two older censuses cited below read the tracked files instead (`git`): entry 1's sign census and -the README's word census under entry 4 — a module not yet added to git is outside their count, +the README's word census under entry 4; a module not yet added to git is outside their count, which every file in a pull request is inside. Each census was shown red, before it was committed, by planting its violation under `lib/` and restoring. A census reads text, so its reach is the reach of its pattern; where a pattern allows a shape, the entry says which. @@ -40,11 +40,11 @@ reach is the reach of its pattern; where a pattern allows a shape, the entry say Three censuses read the **artefact** instead of the text (this one, entry 11's, and the population census's module list), and this one is what the others rest on. `:xref` over the beams compiled from `lib/` lists every module and every function the package calls, whatever -the call was spelled — an alias, a pipe, a capture, `apply` under any name, a call without +the call was spelled: an alias, a pipe, a capture, `apply` under any name, a call without parentheses on a named module, all resolve to the same edge in the compiled form. It is run with Erlang's *built-in functions* included: `:xref` omits calls to BIFs by default, and some -of the functions that matter most on `:erlang` — `apply/3`, `binary_to_term/1`, -`list_to_atom/1`, `binary_to_atom/2` — are BIFs the default listing does not see (measured; +of the functions that matter most on `:erlang` (`apply/3`, `binary_to_term/1`, +`list_to_atom/1`, `binary_to_atom/2`) are BIFs the default listing does not see (measured; `spawn/2` and `open_port/2` it does). The lists are pinned exactly: the modules the package calls (`:xref` itself among them: the declared connectome reads beams with it); on the modules through which code, names, secrets, the @@ -53,10 +53,10 @@ operating system, the disk, another process or another node could be reached (`: `:xref`, `:io_lib`, `:logger`, `Logger`, `:digraph`, `:atomics`, `:telemetry`, `Jason`, `Process`, `GenServer`, `Supervisor`, `Plug.Conn`, `Plug.Exception`, `IO`), the functions; the one atom the package makes from a binary, by the function that makes it; and every atom in -the compiled forms that names a module — an `Elixir.`-prefixed atom is a module name by construction, installed here or not; an Erlang-style one, if this VM can load it — whatever it was written for — +the compiled forms that names a module (an `Elixir.`-prefixed atom is a module name by construction, installed here or not; an Erlang-style one, if this VM can load it): whatever it was written for, a module handed to a supervisor as a child spec's `{m, f, a}`, or to anything else as data, is named there whether or not it is ever called, so that set is pinned too: the called modules -plus eight that arrive through attributes, export lists, option names, a tuple tag — and one, +plus eight that arrive through attributes, export lists, option names, a tuple tag; and one, `json`, that is a local function name OTP 28 turned into a module's name: the loud collision this census is built to have (`shell` would trip it the same way). The eight are exact for the OTP the gate runs; an older OTP without a `json` module reads one fewer. The population is the build's ebin, not Mix's `.app` file, which is @@ -64,7 +64,7 @@ regenerated on a one-second mtime and can miss a module compiled in the same sec library, an evaluator, a socket, a shell, a spawn to another node, a key store, an environment read, a file read or an atom decoded from the wire fails here until it is named: `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" "on the modules that could reach code, names, secrets, the OS or another node, the functions called are exactly the listed ones" "the one atom made from a binary is made in Server.declared_atoms/1" "every atom in the compiled forms that names a module is a called module or one of the nine named as data". -What runs at *compile time* — a module body, an attribute's expression — leaves no call in the +What runs at *compile time* (a module body, an attribute's expression) leaves no call in the beam and is outside every artefact census; the text holds that line instead, by name. **The census's patterns are the list**; this paragraph names their classes, not their spellings. The classes: macro and guard definitions, `quote`, `unquote`, `unquote_splicing`; the Elixir and @@ -74,18 +74,18 @@ Erlang evaluators and compilers (`Code` except `Code.ensure_*`, `:elixir`, `:eli and the disk (`:os.`, `File.`, `:file.`, `:prim_file.`, `:filelib.`, `Path.wildcard`, `:init.`, and twelve named readers on `System` and `Application`); a quoted atom carrying a `\x` or `\u` escape (the two escapes that can spell a letter), any word sigil -at all — one string sigil is allowed by its exact line; every other `~w`, whatever its -delimiter, lines, escapes or modifier, and the macro called by name, is refused — and the +at all (one string sigil is allowed by its exact line; every other `~w`, whatever its +delimiter, lines, escapes or modifier, and the macro called by name, is refused), and the Erlang names above in quotes; an atom built by *interpolation* is the joined-strings edge below, read by nothing; and every `import`, `alias` or `require` that would bring any of these in under another name, across lines. Three allowances by exact line: the package's own version read from `mix.exs`, the tracer's threat model naming the loader it does not call, and the HTTP transport's one word sigil, which makes strings. Beside the census, the -compiler's `warnings_as_errors` (in `mix.exs`) refuses a needlessly quoted atom outright — a +compiler's `warnings_as_errors` (in `mix.exs`) refuses a needlessly quoted atom outright, a bar this package's build has and a stranger's might not. A reach under a name not in those -classes, run in a module body, is held by nothing but a reviewer's eye — the reader that +classes, run in a module body, is held by nothing but a reviewer's eye; the reader that would see a module body by what it does is a compiler tracer, and it is not built. Macros -*invoked* from Elixir and the dependencies — `use GenServer`, `defstruct`, `Logger.error` — +*invoked* from Elixir and the dependencies (`use GenServer`, `defstruct`, `Logger.error`) expand under `lib/` as anywhere and are the dependency list's, held by their names only; the one Elixir macro that reads the disk at expansion, `EEx.function_from_file`, is barred by its name. @@ -109,18 +109,18 @@ The core itself is unchanged by the seam: entry 12's two tests hold as they stoo | # | The package will never | Why | Enforced by | | -- | -- | -- | -- | -| 1 | **compute or populate a sign.** `:allow`, `:deny`, `:hold` and `:ungoverned` are a consumer's; the package writes `:unset` — no sign has been supplied to it — into every edge's sign slot, on both graphs, and treats no sign as suppression. | A sign is a verdict. The package exports topology and lets the verdict be somebody else's, so that nothing in it can be mistaken for approval; and a sign it cannot attribute to a decider is not a reason to leave an edge out of a record. | `test/beam_mcp/connectome/census_test.exs` "no code line under lib/ writes or names a sign other than :unset" "no code line under lib/ filters, hides or downgrades an edge on the basis of its sign"; `test/beam_mcp/readme_claims_test.exs` "the package writes only :unset into the sign slot, on both graphs" | -| 2 | **hold a key.** No line under `lib/` generates, loads, decodes or stores key material. The one cryptographic function the package calls is `:crypto.hash/2`, a digest with no key in it — one site, over canonical bytes, the algorithm a variable bound from the caller's option (SHA-256 by default, SHA-384 or SHA-512 by choice, the envelope naming which) and never a literal, and the count is pinned so that a second site has to say what it hashes. | A package that holds a key can be asked to use it. The canonical bytes exist so that a consumer can sign them without importing this package. | `test/beam_mcp/boundary/no_key_holding_test.exs` "no line under lib/ names key material or calls a crypto function other than :crypto.hash/2" ":crypto.hash/2 is called at one site, over canonical bytes, with the algorithm a variable"; `test/beam_mcp/boundary/package_reach_test.exs` "on the modules that could reach code, names, secrets, the OS or another node, the functions called are exactly the listed ones" | -| 3 | **make a signature of its own.** No signing or MAC primitive is called under `lib/`, and no key is held. The one `sign/2` defined there is `BeamMCP.Signer.None`, a no-op answering `{:error, :no_signer}`; the one call of a signer is `BeamMCP.Connectome.Canonical.signature/3`, which hands the canonical bytes to a host-supplied module implementing `BeamMCP.Signer` — exactly one callback, `sign(canonical_bytes, opts)`, two arguments with those names — and places what comes back beside them. The key and the primitive are in the separate package `beam_mcp_signer` (Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`), which a host attaches; this package does not depend on it. | Signing bytes publishes nothing about who decides what; a richer callback would, so the census pins the shape and any widening is a visible act. The `sign` field an edge carries is the host's verdict slot (entry 1), a value and not an act. | `test/beam_mcp/boundary/no_signature_test.exs` "no line under lib/ calls a signing or MAC primitive" "exactly one `def sign` under lib/: the no-op, spelled as pinned, in its own file" "the behaviour has exactly one callback, sign/2, with the pinned argument names and return" "exactly one call of a signer under lib/: signature/3's, over encode/2's bytes"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" | -| 4 | **decide authority.** It writes no verdict (entry 1: the sign slot is only ever `:unset`) and names no receipt (a signed record that a call happened), no approval (a decision that a call may proceed), no risk tier (a ranking of calls by consequence), no egress and no mask (the withholding or rewriting of what leaves the system) — any word containing *receipt*, *approv*, *egress*, *tier* or *mask* (a "frontier" in prose would trip it, loudly, and be read). | Every one of these is a decision about the host's tools, and the package holds none of them; the moment it held one, its topology could be mistaken for a verdict. The words "verdict" and "authority" are not barred: under `lib/` they name the host's slot in the edge's docs and the diff's own class, *dead authority* — terms the package defines, not acts it performs. The acts are barred in the spellings code uses as well as prose. | `test/beam_mcp/boundary/no_authority_test.exs` "no line under lib/ names a receipt, an approval, a risk tier, egress or a mask, in any spelling"; `test/beam_mcp/connectome/census_test.exs` "no code line under lib/ writes or names a sign other than :unset"; `test/beam_mcp/readme_claims_test.exs` "deliberately out: no line under lib/ names a receipt, an approval, a risk tier or egress" | +| 1 | **compute or populate a sign.** `:allow`, `:deny`, `:hold` and `:ungoverned` are a consumer's; the package writes `:unset` (no sign has been supplied to it) into every edge's sign slot, on both graphs, and treats no sign as suppression. | A sign is a verdict. The package exports topology and lets the verdict be somebody else's, so that nothing in it can be mistaken for approval; and a sign it cannot attribute to a decider is not a reason to leave an edge out of a record. | `test/beam_mcp/connectome/census_test.exs` "no code line under lib/ writes or names a sign other than :unset" "no code line under lib/ filters, hides or downgrades an edge on the basis of its sign"; `test/beam_mcp/readme_claims_test.exs` "the package writes only :unset into the sign slot, on both graphs" | +| 2 | **hold a key.** No line under `lib/` generates, loads, decodes or stores key material. The one cryptographic function the package calls is `:crypto.hash/2`, a digest with no key in it: one site, over canonical bytes, the algorithm a variable bound from the caller's option (SHA-256 by default, SHA-384 or SHA-512 by choice, the envelope naming which) and never a literal, and the count is pinned so that a second site has to say what it hashes. | A package that holds a key can be asked to use it. The canonical bytes exist so that a consumer can sign them without importing this package. | `test/beam_mcp/boundary/no_key_holding_test.exs` "no line under lib/ names key material or calls a crypto function other than :crypto.hash/2" ":crypto.hash/2 is called at one site, over canonical bytes, with the algorithm a variable"; `test/beam_mcp/boundary/package_reach_test.exs` "on the modules that could reach code, names, secrets, the OS or another node, the functions called are exactly the listed ones" | +| 3 | **make a signature of its own.** No signing or MAC primitive is called under `lib/`, and no key is held. The one `sign/2` defined there is `BeamMCP.Signer.None`, a no-op answering `{:error, :no_signer}`; the one call of a signer is `BeamMCP.Connectome.Canonical.signature/3`, which hands the canonical bytes to a host-supplied module implementing `BeamMCP.Signer` (exactly one callback, `sign(canonical_bytes, opts)`, two arguments with those names), and places what comes back beside them. The key and the primitive are in the separate package `beam_mcp_signer` (Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`), which a host attaches; this package does not depend on it. | Signing bytes publishes nothing about who decides what; a richer callback would, so the census pins the shape and any widening is a visible act. The `sign` field an edge carries is the host's verdict slot (entry 1), a value and not an act. | `test/beam_mcp/boundary/no_signature_test.exs` "no line under lib/ calls a signing or MAC primitive" "exactly one `def sign` under lib/: the no-op, spelled as pinned, in its own file" "the behaviour has exactly one callback, sign/2, with the pinned argument names and return" "exactly one call of a signer under lib/: signature/3's, over encode/2's bytes"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" | +| 4 | **decide authority.** It writes no verdict (entry 1: the sign slot is only ever `:unset`) and names no receipt (a signed record that a call happened), no approval (a decision that a call may proceed), no risk tier (a ranking of calls by consequence), no egress and no mask (the withholding or rewriting of what leaves the system): any word containing *receipt*, *approv*, *egress*, *tier* or *mask* (a "frontier" in prose would trip it, loudly, and be read). | Every one of these is a decision about the host's tools, and the package holds none of them; the moment it held one, its topology could be mistaken for a verdict. The words "verdict" and "authority" are not barred: under `lib/` they name the host's slot in the edge's docs and the diff's own class, *dead authority*. They are terms the package defines, not acts it performs. The acts are barred in the spellings code uses as well as prose. | `test/beam_mcp/boundary/no_authority_test.exs` "no line under lib/ names a receipt, an approval, a risk tier, egress or a mask, in any spelling"; `test/beam_mcp/connectome/census_test.exs` "no code line under lib/ writes or names a sign other than :unset"; `test/beam_mcp/readme_claims_test.exs` "deliberately out: no line under lib/ names a receipt, an approval, a risk tier or egress" | | 5 | **put a payload byte into the observed graph.** Edge identity only: never an argument, a result, a header, an error message or a stack frame's contents. | A wiring diagram that carries payloads is a log, and a log of tool calls is the most sensitive artefact a host produces. The observed graph is safe to export because it cannot contain what was said. | `test/beam_mcp/connectome/observed_test.exs` "a marker in a nested argument map and in a uri argument is absent from rows, bytes, sidecar and latency" "a marker in the error a dispatch returns is absent" "a marker in an exception a dispatch raises is absent, and the edge was still recorded" "the :stop event itself carries no argument, result or header bytes" "dispatch_opts never enter: a secret handed to every dispatch is in no row, byte or summary" "request headers carrying the marker reach neither the events nor the rows" "a throw and an exit from the dispatch are exceptions of their kind, with marker-free frames; a crafted error_info is dropped from the frames"; `test/beam_mcp/connectome/tracer_test.exs` "a registered name is identity: a secret in a name is published in the bytes, the message beside it is not"; `test/beam_mcp/connectome/diff_test.exs` "an observed graph the collector built from a call carrying a marker diffs to bytes with no marker"; `test/beam_mcp/readme_claims_test.exs` "the observed graph carries edge identity only, never a payload byte" | -| 6 | **claim an MCP capability the specification does not define.** No topology or reachability capability on the wire; `connectome://` is the package's own URI scheme, not a claimed capability. | Capabilities are negotiated with clients that read the specification, not this README. An invented key is a promise no client can act on. The advertised keys are held to `ServerCapabilities` as each revision's schema defines it — a copy of the two key sets taken from the schema files on 2026-09-15 and cited in the test (`tasks` in 2025-11-25; `extensions` in 2026-07-28). The entry bars keys the specification does not define, at the top level and one level under each capability whose sub-keys the schema names (`tools`: `listChanged` — the only capability advertised today; `resources`, `prompts` and `tasks` are held the day they are advertised; `completions`, `logging`, `experimental` and `extensions` are open objects and nothing is read under them; a third level is not read); a key it does define and this package does not implement is a different question, answered by the README. | `test/beam_mcp/boundary/no_invented_capability_test.exs` "server/discover advertises only keys the 2026-07-28 schema defines" "the initialize result advertises only keys the 2025-11-25 schema defines" "no line under lib/ names a topology or reachability capability on the wire" | -| 7 | **issue or honour a session identifier.** Over HTTP no response carries `Mcp-Session-Id`, a request carrying one gets the same status and body as one that does not (held on four methods, including an unknown one), and no line under `lib/` reads or writes one — the name is barred in every delimiter and casing (`mcp-session-id`, `McpSessionId`, `session_id`), with one allowance: the hyphenated header name written directly after the words "no `" — the transport's own denial. | Every request stands alone; the 2026-07-28 transport removed sessions. Refusing unestablished callers on a transport where that matters is the host's job, stated in the README. | `test/beam_mcp/boundary/no_session_test.exs` "no response carries an mcp-session-id header, and a request carrying one is answered as if it did not" "no code line under lib/ reads or writes a session identifier" | -| 8 | **carry OAuth.** No authorization flow, discovery document, token endpoint or bearer handling under `lib/` — the `authorization` header is not even read there; it reaches the host's hook untouched. The transport offers `:authorize` and `:authorize_body` hooks and performs no cryptography. | Verifying is the host's work; making it possible is the transport's. A package that performs no cryptography (entries 2 and 3) cannot honestly offer an OAuth server. | `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no OAuth under lib/" | -| 9 | **be a client.** No module under `lib/` names itself a client, opens an outbound connection, or sends an `initialize` request. The artefact holds the outbound half whatever the spelling: none of `gen_tcp`, `ssl`, `socket`, `gen_udp`, `ssh`, `httpc`, `inets`, `os`, `peer`, `net_kernel`, `rpc`, `Port`, `File` or any HTTP client is among the modules the package calls, and of `:erlang` only `spawn/1` — the local one — is; the text census names the same by word. A message to a process registered on another node (`send/2`, `GenServer.call/2` to a `{name, node}`) is the one outbound act the compiled form cannot tell from a local one; every `initialize` under `lib/` is a clause head that receives one, or the list of methods the modern era removed. | A server that also calls out has two threat models, and a page like this one for each; this package keeps one. | `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no client under lib/: no client module, no outbound connection, initialize only ever received"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" | -| 10 | **enumerate all paths or match motifs in the connectome.** `all_paths` is refused by name, always; it is not capped; its one definition under `lib/` is the refusal, and no function under `lib/` carries *motif*, *isomorph*, *subgraph*, *path* or *walk* in its name — except the bare names `path` and `walk` — reach's own witness builder and dominator pass — allowed by name alone, in any module and at any arity. | The number of paths is exponential in the graph; a cap would be a promise to answer "some of them", which is worse than no answer. Motif matching is refused for the same reason. The reachability questions the package does answer are on the reach page. | `test/beam_mcp/connectome/reach_test.exs` "all-paths enumeration is refused by name, not attempted"; `test/beam_mcp/boundary/no_path_enumeration_test.exs` "the only definition of all_paths under lib/ is the refusal, and no motif matcher is defined" | -| 11 | **hold a tool, a domain, or a concrete catalog.** No module under `lib/` implements `BeamMCP.Catalog` — by `@behaviour`, or by defining or delegating `capabilities/0`, which is all the package asks of a catalog — and nothing under `lib/` builds a `%BeamMCP.ToolSpec{}`, in any spelling: in the compiled form of every module under `lib/`, the atom `BeamMCP.ToolSpec` occurs only inside a map *pattern* — a literal, an alias, a `struct/2`, a `Map.put` of `__struct__`, a map update of `__struct__`, a map *key*, a `%__MODULE__{}` in its own module or a variable bound to the module all leave the atom somewhere else, and the compiler-generated sites are not skipped but pinned — the struct's own `__struct__/0,1` and Catalog's callback info, exactly, so an `unquote` of a hand-built syntax tree marked generated is one site too many. What this does not see is a tool derived from a tool the package was handed — a matched struct updated field by field carries no atom of its own; nothing under `lib/` does that today, and it is a reviewer's line. The struct is defined there, matched there, and never constructed there. The catalog is reached through three callees, `capabilities/0` at five sites, `read_resource/1` at one and `get_prompt/2` at one — in the compiled form every call through a module known only at runtime, with parentheses, is one of those seven; the one read is reached only after the catalog's own `capabilities/0` has listed the uri or a template that matches it, and the one render only after it has listed the prompt and the tools validator has passed the arguments (a call through a function *value* is a closure, the host's dispatch and hooks or the package's own, and not a module call); and the one spelling the compiled form cannot tell from a field access — `m.capabilities` without parentheses, a deprecated form — is held by the list of every name the package reaches by dot syntax, pinned exactly — its own fields, `conn.method`, and a rescued exception's `__struct__` (names, not module–name pairs: a runtime module whose export shares a field's name, `m.nodes` with `m = :erlang`, is inside the list). The catalog and the dispatch are injected by the host. | The README's opening sentence: the package holds no tools, no domain, and no policy — a commodity protocol layer with nothing of its own to protect or to sell. | `test/beam_mcp/boundary/no_catalog_test.exs` "no module under lib/ implements BeamMCP.Catalog" "no line under lib/ constructs a tool" "the catalog is called through three callees: capabilities/0 at five sites, read_resource/1 at one, get_prompt/2 at one" "every name the package reaches by dot syntax is on its pinned list" | -| 12 | **run a multi-round-trip request.** The `2026-07-28` revision lets a server answer `tools/call`, `resources/read` or `prompts/get` with an `InputRequiredResult` and finish on a later request carrying `inputResponses` and a `requestState` of the server's own. This package answers every request completely or refuses it: the one `resultType` written under `lib/` is `"complete"`, at one site, and neither continuation parameter is read anywhere — a request carrying them is served as if it carried neither, since nothing here ever asked for input. | An input-required round trip is a conversation with state between two messages — what was asked, what came back, what the server had decided so far — and this core has no process and no state of its own by design: one message in, one response out. The `requestState` the revision offers as the server's opaque continuation would carry the server's own decision state for a client to hand back — unlike the pagination cursor, which names a position any client may name by other means and carries no decision. A host that wants the round trip owns exactly the state it needs to run it, above this core. Named by the owner as the one item of the `2026-07-28` surface nobody had placed; placed here, out, so nobody later improves it in by accident. | `test/beam_mcp/boundary/no_mrtr_test.exs` "the only resultType written under lib/ is complete, at one site" "no line under lib/ reads inputResponses or requestState, and none names InputRequiredResult"; `test/beam_mcp/mrtr_wire_test.exs` "on the core, at both eras, the answer with the continuation parameters is the bare answer" "through the HTTP transport the answer with the continuation parameters is the bare answer" | +| 6 | **claim an MCP capability the specification does not define.** No topology or reachability capability on the wire; `connectome://` is the package's own URI scheme, not a claimed capability. | Capabilities are negotiated with clients that read the specification, not this README. An invented key is a promise no client can act on. The advertised keys are held to `ServerCapabilities` as each revision's schema defines it: a copy of the two key sets taken from the schema files on 2026-09-15 and cited in the test (`tasks` in 2025-11-25; `extensions` in 2026-07-28). The entry bars keys the specification does not define, at the top level and one level under each capability whose sub-keys the schema names (`tools`: `listChanged`, the only capability advertised today; `resources`, `prompts` and `tasks` are held the day they are advertised; `completions`, `logging`, `experimental` and `extensions` are open objects and nothing is read under them; a third level is not read); a key it does define and this package does not implement is a different question, answered by the README. | `test/beam_mcp/boundary/no_invented_capability_test.exs` "server/discover advertises only keys the 2026-07-28 schema defines" "the initialize result advertises only keys the 2025-11-25 schema defines" "no line under lib/ names a topology or reachability capability on the wire" | +| 7 | **issue or honour a session identifier.** Over HTTP no response carries `Mcp-Session-Id`, a request carrying one gets the same status and body as one that does not (held on four methods, including an unknown one), and no line under `lib/` reads or writes one; the name is barred in every delimiter and casing (`mcp-session-id`, `McpSessionId`, `session_id`), with one allowance: the hyphenated header name written directly after the words "no `", the transport's own denial. | Every request stands alone; the 2026-07-28 transport removed sessions. Refusing unestablished callers on a transport where that matters is the host's job, stated in the README. | `test/beam_mcp/boundary/no_session_test.exs` "no response carries an mcp-session-id header, and a request carrying one is answered as if it did not" "no code line under lib/ reads or writes a session identifier" | +| 8 | **carry OAuth.** No authorization flow, discovery document, token endpoint or bearer handling under `lib/`: the `authorization` header is not even read there; it reaches the host's hook untouched. The transport offers `:authorize` and `:authorize_body` hooks and performs no cryptography. | Verifying is the host's work; making it possible is the transport's. A package that performs no cryptography (entries 2 and 3) cannot honestly offer an OAuth server. | `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no OAuth under lib/" | +| 9 | **be a client.** No module under `lib/` names itself a client, opens an outbound connection, or sends an `initialize` request. The artefact holds the outbound half whatever the spelling: none of `gen_tcp`, `ssl`, `socket`, `gen_udp`, `ssh`, `httpc`, `inets`, `os`, `peer`, `net_kernel`, `rpc`, `Port`, `File` or any HTTP client is among the modules the package calls, and of `:erlang` only `spawn/1` (the local one) is; the text census names the same by word. A message to a process registered on another node (`send/2`, `GenServer.call/2` to a `{name, node}`) is the one outbound act the compiled form cannot tell from a local one; every `initialize` under `lib/` is a clause head that receives one, or the list of methods the modern era removed. | A server that also calls out has two threat models, and a page like this one for each; this package keeps one. | `test/beam_mcp/boundary/no_oauth_no_client_test.exs` "no client under lib/: no client module, no outbound connection, initialize only ever received"; `test/beam_mcp/boundary/package_reach_test.exs` "the modules the package calls are exactly the listed ones" | +| 10 | **enumerate all paths or match motifs in the connectome.** `all_paths` is refused by name, always; it is not capped; its one definition under `lib/` is the refusal, and no function under `lib/` carries *motif*, *isomorph*, *subgraph*, *path* or *walk* in its name, except the bare names `path` and `walk` (reach's own witness builder and dominator pass), allowed by name alone, in any module and at any arity. | The number of paths is exponential in the graph; a cap would be a promise to answer "some of them", which is worse than no answer. Motif matching is refused for the same reason. The reachability questions the package does answer are on the reach page. | `test/beam_mcp/connectome/reach_test.exs` "all-paths enumeration is refused by name, not attempted"; `test/beam_mcp/boundary/no_path_enumeration_test.exs` "the only definition of all_paths under lib/ is the refusal, and no motif matcher is defined" | +| 11 | **hold a tool, a domain, or a concrete catalog.** No module under `lib/` implements `BeamMCP.Catalog` (by `@behaviour`, or by defining or delegating `capabilities/0`, which is all the package asks of a catalog) and nothing under `lib/` builds a `%BeamMCP.ToolSpec{}`, in any spelling: in the compiled form of every module under `lib/`, the atom `BeamMCP.ToolSpec` occurs only inside a map *pattern*: a literal, an alias, a `struct/2`, a `Map.put` of `__struct__`, a map update of `__struct__`, a map *key*, a `%__MODULE__{}` in its own module or a variable bound to the module all leave the atom somewhere else, and the compiler-generated sites are not skipped but pinned (the struct's own `__struct__/0,1` and Catalog's callback info, exactly), so an `unquote` of a hand-built syntax tree marked generated is one site too many. What this does not see is a tool derived from a tool the package was handed: a matched struct updated field by field carries no atom of its own; nothing under `lib/` does that today, and it is a reviewer's line. The struct is defined there, matched there, and never constructed there. The catalog is reached through three callees, `capabilities/0` at five sites, `read_resource/1` at one and `get_prompt/2` at one: in the compiled form every call through a module known only at runtime, with parentheses, is one of those seven; the one read is reached only after the catalog's own `capabilities/0` has listed the uri or a template that matches it, and the one render only after it has listed the prompt and the tools validator has passed the arguments (a call through a function *value* is a closure, the host's dispatch and hooks or the package's own, and not a module call); and the one spelling the compiled form cannot tell from a field access (`m.capabilities` without parentheses, a deprecated form) is held by the list of every name the package reaches by dot syntax, pinned exactly: its own fields, `conn.method`, and a rescued exception's `__struct__` (names, not module–name pairs: a runtime module whose export shares a field's name, `m.nodes` with `m = :erlang`, is inside the list). The catalog and the dispatch are injected by the host. | The README's opening sentence: the package holds no tools, no domain, and no policy; a commodity protocol layer with nothing of its own to protect or to sell. | `test/beam_mcp/boundary/no_catalog_test.exs` "no module under lib/ implements BeamMCP.Catalog" "no line under lib/ constructs a tool" "the catalog is called through three callees: capabilities/0 at five sites, read_resource/1 at one, get_prompt/2 at one" "every name the package reaches by dot syntax is on its pinned list" | +| 12 | **run a multi-round-trip request.** The `2026-07-28` revision lets a server answer `tools/call`, `resources/read` or `prompts/get` with an `InputRequiredResult` and finish on a later request carrying `inputResponses` and a `requestState` of the server's own. This package answers every request completely or refuses it: the one `resultType` written under `lib/` is `"complete"`, at one site, and neither continuation parameter is read anywhere: a request carrying them is served as if it carried neither, since nothing here ever asked for input. | An input-required round trip is a conversation with state between two messages (what was asked, what came back, what the server had decided so far), and this core has no process and no state of its own by design: one message in, one response out. The `requestState` the revision offers as the server's opaque continuation would carry the server's own decision state for a client to hand back, unlike the pagination cursor, which names a position any client may name by other means and carries no decision. A host that wants the round trip owns exactly the state it needs to run it, above this core. Named by the owner as the one item of the `2026-07-28` surface nobody had placed; placed here, out, so nobody later improves it in by accident. | `test/beam_mcp/boundary/no_mrtr_test.exs` "the only resultType written under lib/ is complete, at one site" "no line under lib/ reads inputResponses or requestState, and none names InputRequiredResult"; `test/beam_mcp/mrtr_wire_test.exs` "on the core, at both eras, the answer with the continuation parameters is the bare answer" "through the HTTP transport the answer with the continuation parameters is the bare answer" | ## What a census does not prove @@ -129,23 +129,23 @@ reads text, and text has edges worth stating: - The censuses bar the acts by their written names (`:crypto.sign`, `:public_key.`, `:httpc.`, `Mcp-Session-Id`, and so on), whatever delimiter the name is written in (entry 7 allows one: - backticks, prose). An act under a name the patterns do not list — in this package's own code - or in a library — would not be seen. + backticks, prose). An act under a name the patterns do not list (in this package's own code + or in a library) would not be seen. For key material, which has no name of its own, the patterns bar the ways it would arrive: an environment read, a `_KEY` constant, a decoder, a generator. The libraries this package can - reach are the ones in `mix.lock` (the development tools included) — `jason` and `telemetry`, + reach are the ones in `mix.lock` (the development tools included): `jason` and `telemetry`, the optional `plug` and `bandit`, and what they bring, which includes `plug_crypto`, a signing and key-derivation library; it is barred by name in entries 2 and 3. Nothing in this repository audits what a dependency does; the lock file is the list, and a reader who wants the guarantee reads it. - A name assembled at runtime would defeat every text census above. The acts that assemble one - — `apply`, `Module.concat`, an evaluator, a loader, an atom made from a binary — are calls, + (`apply`, `Module.concat`, an evaluator, a loader, an atom made from a binary) are calls, and the artefact census sees every *runtime* call by its compiled target, so those are held whatever they are spelled; what runs at compile time is held by the text (the reader - paragraph). What has no call in it — a barred *word* spelled as two strings joined, - `"mcp-" <> "session-id"`, a header name that is data and not code — is outside every census on + paragraph). What has no call in it (a barred *word* spelled as two strings joined, + `"mcp-" <> "session-id"`, a header name that is data and not code) is outside every census on this page and is not pinned; a reviewer reads for it, as for the compile-time reach above: those two are what a reviewer's eye holds here. The text reader drops a line beginning `#` - as a comment unless it carries a `#{` — an interpolation inside a string is code and is read. + as a comment unless it carries a `#{`: an interpolation inside a string is code and is read. Two things under `lib/` are called without a written name by design and are the host's code: its catalog module (`capabilities/0` at five sites, `read_resource/1` at one and `get_prompt/2` at one: three diff --git a/lib/beam_mcp/catalog.ex b/lib/beam_mcp/catalog.ex index aea5e247..cf8572e2 100644 --- a/lib/beam_mcp/catalog.ex +++ b/lib/beam_mcp/catalog.ex @@ -7,7 +7,7 @@ defmodule BeamMCP.Catalog do The server holds no catalog of its own. It advertises what `capabilities/0` returns and accepts a `tools/call` only for a tool `capabilities/0` names, so one implementation governs - both — a tool advertised by `tools/list` and refused by `tools/call` is the defect this + both: a tool advertised by `tools/list` and refused by `tools/call` is the defect this behaviour exists to make impossible. ## The shape @@ -23,7 +23,7 @@ defmodule BeamMCP.Catalog do a key, and every host that had written `capabilities/0` with an empty list kept working. `prompts` holds `BeamMCP.PromptSpec` structs, read by `prompts/list` and `prompts/get`. - A key that is absent is a malformed catalog, not an empty one — the two are different claims + A key that is absent is a malformed catalog, not an empty one; the two are different claims and only one of them is checkable. ## Resources: advertise and read from one reader, as tools do @@ -71,7 +71,7 @@ defmodule BeamMCP.Catalog do This behaviour replaces `BeamMCP.ToolCatalog`, whose callback was `BeamMCP.ToolCatalog.all/0`, returning a list. Keeping the name while changing the return from a list to a map would compile against every - existing host and fail at the first request with a `BadMapError` — a silent shape change, + existing host and fail at the first request with a `BadMapError`, a silent shape change, which is the defect class this repository keeps finding. Renaming makes the break arrive at compile time as an unimplemented callback, which is the loudest place it can arrive. """ @@ -142,7 +142,7 @@ defmodule BeamMCP.Catalog do **This is the single reader, and that is the point rather than a convenience.** Both paths go through it: `tools/list` advertises what it returns, and `fetch/2` decides callability from the same call. Slice 002 fixed a real defect where advertising honoured an injected catalog - and calling ignored it — two readers, two answers. One function is how that stays fixed, and + and calling ignored it: two readers, two answers. One function is how that stays fixed, and a mutant that gives the two paths different sources is scored in the repository's record of the catalog generalisation (a slice archive; not in the package). """ @@ -246,7 +246,7 @@ defmodule BeamMCP.Catalog do ## This function raises on a malformed catalog, and the `@spec` does not say so Stated rather than caught, and the reason is the guarantee above. A host bug turned into - `:error` is indistinguishable from "no such tool" — so a catalog that is broken would present + `:error` is indistinguishable from "no such tool", so a catalog that is broken would present exactly as a catalog that is working and simply does not have that tool. That is the advertise-versus-call disagreement this behaviour exists to prevent, reintroduced by the error handling meant to be defensive. diff --git a/lib/beam_mcp/connectome/canonical.ex b/lib/beam_mcp/connectome/canonical.ex index 03fe53d5..7bfb79d5 100644 --- a/lib/beam_mcp/connectome/canonical.ex +++ b/lib/beam_mcp/connectome/canonical.ex @@ -322,7 +322,7 @@ defmodule BeamMCP.Connectome.Canonical do the callback, `{:error, {:signer, {:not_a_signer, signer}}}`; a signer's `{:error, reason}` as `{:error, {:signer, reason}}` (`BeamMCP.Signer.None` gives `{:error, {:signer, :no_signer}}`); a signer answering `{:ok, x}` with `x` not a binary, `{:error, {:signer, {:not_a_signature, x}}}`. - A signer that raises, raises — it is the host's code. + A signer that raises, raises; it is the host's code. """ @spec signature(Graph.t(), module(), keyword()) :: {:ok, diff --git a/lib/beam_mcp/json.ex b/lib/beam_mcp/json.ex index 1b668af2..930cf842 100644 --- a/lib/beam_mcp/json.ex +++ b/lib/beam_mcp/json.ex @@ -47,11 +47,11 @@ defmodule BeamMCP.JSON do Repeated keys are refused rather than resolved because two parsers resolve them two ways: Jason keeps the first, most others the last. A hop in front of this server that routes on the last `"name"` while this server executes the first is two sources of truth inside one - body — the disagreement the header–body match exists to close. Jason is asked for ordered + body: the disagreement the header–body match exists to close. Jason is asked for ordered objects, which keep every pair, so the repeat is visible; the objects are then read once into maps, which is what every caller expects. The repeat is found in the decoded objects and not in the bytes on purpose: a key is compared after unescaping, as every decoder - compares it — `"a"` and `"\\u0061"` are one key — and a byte walk that compared raw keys + compares it (`"a"` and `"\\u0061"` are one key), and a byte walk that compared raw keys would miss exactly the pair a hop in front would merge. What that costs, per shape, is measured on `docs/threat-model.md`: a request-sized body twice a 2 µs decode; a 1 MiB body 2.0–2.6× the decoder's own time, the key-dense shapes at the top of that band. diff --git a/lib/beam_mcp/schema.ex b/lib/beam_mcp/schema.ex index e2b03cbc..3fff28ca 100644 --- a/lib/beam_mcp/schema.ex +++ b/lib/beam_mcp/schema.ex @@ -18,7 +18,7 @@ defmodule BeamMCP.Schema do So this module refuses rather than guesses, and the server enforces the same schema it advertised rather than a second one compiled in beside it. - This is a deliberately small subset of JSON Schema — the keywords the server actually + This is a deliberately small subset of JSON Schema: the keywords the server actually uses. It is not a general validator, and it refuses rather than guesses. """ diff --git a/lib/beam_mcp/server.ex b/lib/beam_mcp/server.ex index 14cbff00..2ea6f268 100644 --- a/lib/beam_mcp/server.ex +++ b/lib/beam_mcp/server.ex @@ -10,14 +10,14 @@ defmodule BeamMCP.Server do The protocol core: one message in, one response out, no process and no state of its own. `handle_message/2` takes a decoded JSON-RPC message and the state from `new/1`, and returns - the next state and a response — or `nil` where the protocol defines no reply. A transport + the next state and a response, or `nil` where the protocol defines no reply. A transport supplies the bytes; this module never touches them. ## Two eras It serves `2026-07-28` and `2025-11-25`, and tells them apart the way the specification says a dual-era server should: a request carrying per-request `_meta` is served statelessly, and - an `initialize` request selects legacy semantics. `_meta` decides only the statelessness — + an `initialize` request selects legacy semantics. `_meta` decides only the statelessness; the revision it *names* then decides the method table and the result envelope, so a request declaring `2025-11-25` through `_meta` gets that revision's semantics, not the modern ones. A request naming a revision it does not support gets `UnsupportedProtocolVersionError` diff --git a/lib/beam_mcp/transport/http.ex b/lib/beam_mcp/transport/http.ex index eb4b2d14..3fe99478 100644 --- a/lib/beam_mcp/transport/http.ex +++ b/lib/beam_mcp/transport/http.ex @@ -32,7 +32,7 @@ if Code.ensure_loaded?(Plug) do Stateless Streamable HTTP transport: a `Plug` serving `2026-07-28` at one endpoint. A second caller of `BeamMCP.Server.handle_message/2`, which this module does not change. - Every request stands alone — **no sessions, no `Mcp-Session-Id`, no SSE resumability**, + Every request stands alone: **no sessions, no `Mcp-Session-Id`, no SSE resumability**, all three removed from the transport in `2026-07-28`. Available only when `plug` is present. `plug` and `bandit` are optional dependencies, so a @@ -49,9 +49,9 @@ if Code.ensure_loaded?(Plug) do So both decisions are **required options with no defaults**. A host that omits either gets an `ArgumentError` when the Plug is initialised: - * `:authorize` — `(Plug.Conn.t() -> :ok | {:error, term()})`, called before any message + * `:authorize`: `(Plug.Conn.t() -> :ok | {:error, term()})`, called before any message is handled. Whatever it returns as a reason goes to the log, never to the caller. - * `:allowed_origins` — `[String.t()]` or `:any`. The specification makes validating + * `:allowed_origins`: `[String.t()]` or `:any`. The specification makes validating `Origin` a MUST, to prevent DNS rebinding; which origins are legitimate is host knowledge. `:any` must be chosen explicitly. @@ -62,44 +62,44 @@ if Code.ensure_loaded?(Plug) do The `:authorize` hook runs **before** the body is read, which is what lets it refuse an unauthenticated caller without buffering megabytes on their behalf. The cost of that - position is that it cannot see the body, so body-signature authentication — HMAC over the - payload, an asymmetric signature — is not merely awkward through it but structurally + position is that it cannot see the body, so body-signature authentication (HMAC over the + payload, an asymmetric signature) is not merely awkward through it but structurally impossible: there is no argument through which the bytes arrive. - * `:authorize_body` — `(Plug.Conn.t(), binary() -> :ok | {:error, term()})`, optional, + * `:authorize_body`: `(Plug.Conn.t(), binary() -> :ok | {:error, term()})`, optional, called **after** the body is read and **before** it is decoded. The second argument is the request body exactly as received. Whatever it returns as a reason goes to the log, never to the caller. - * `:read_timeout` — positive integer, milliseconds, default `#{@read_timeout_default}`. One whole-body + * `:read_timeout`: positive integer, milliseconds, default `#{@read_timeout_default}`. One whole-body deadline, this package's own: the body is read in pieces against one clock, each read given what remains, so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads (over HTTP/2 the adapter's reader is asked for less than one frame, so every DATA frame, an empty one included, returns to this clock; a stream kept open by control - frames alone — a WINDOW_UPDATE, or a HEADERS without END_STREAM — is held past the + frames alone, a WINDOW_UPDATE or a HEADERS without END_STREAM, is held past the deadline by the adapter's own wait, which nothing outside it can end through an interface the adapter offers: one frame per deadline holds a stream process indefinitely, and whatever body then comes is refused; a WINDOW_UPDATE costs the client thirteen bytes and the host nothing accumulated, a HEADERS without END_STREAM writes a - warning line per frame to the host's log carrying the client's header bytes — the + warning line per frame to the host's log carrying the client's header bytes; the threat model states both, and the two `Bandit` listener options that bound the exposure: `http_2_options: [default_local_settings: [max_concurrent_streams: n]]` caps the held streams per connection, `http_2_options: [enabled: false]` removes HTTP/2 from the listener). That per-stream hold the adapter owns is bounded in DURATION here by `:connection_timeout` below. A body must declare its - length — `transfer-encoding: chunked` is refused with `411` before the read, since a + length; `transfer-encoding: chunked` is refused with `411` before the read, since a chunked body is read chunk by chunk on a per-chunk clock that no deadline above it can bound. The `408` is this Plug's JSON-RPC refusal, with `connection: close` over HTTP/1.1 (over HTTP/2 the stream ends - with the response); nothing is written to the host's log for it — the adapter's own + with the response); nothing is written to the host's log for it: the adapter's own error-level line at its read timeout no longer fires, since the deadline is this Plug's. - * `:connection_timeout` — positive integer, milliseconds, default twice `:read_timeout`. + * `:connection_timeout`: positive integer, milliseconds, default twice `:read_timeout`. The whole-body deadline above is a stream's; over HTTP/2 a stream held open by control frames alone cannot be ended from outside the adapter, but its **connection** can. When a body read has been blocked this long and nothing else on the connection is still within its own body deadline, the connection is closed with a `GOAWAY` the client can - read — an OTP `GenServer.stop` on the socket handler, not a forged adapter message or a - reset. So the residue the adapter owns is bounded in duration by this option and in + read (an OTP `GenServer.stop` on the socket handler, not a forged adapter message or a + reset). So the residue the adapter owns is bounded in duration by this option and in count by `http_2_options`'s `max_concurrent_streams`. The cost is per connection: the client's other legitimate streams still open on that connection end with it, so a host multiplexing streams that outlive one body read raises this. Twice the read deadline by @@ -114,7 +114,7 @@ if Code.ensure_loaded?(Plug) do signature while looking like a fault in the host's cryptography. Absent, the hook is skipped and nothing changes. Present, it must be a 2-arity function or - `init/1` raises — a wrong arity is a startup failure rather than a per-request one. + `init/1` raises; a wrong arity is a startup failure rather than a per-request one. **This package performs no cryptography.** The hook is called `:authorize_body` rather than `:verify_signature` because verifying a signature is the host's work; making it possible is @@ -1213,13 +1213,13 @@ if Code.ensure_loaded?(Plug) do entries else # A host catalog that RAISES is not a catalog with no such tool, and collapsing the two - # into [] would let a raising catalog silently disable header mirroring — the check + # into [] would let a raising catalog silently disable header mirroring: the check # would pass because it inspected nothing. The fault is returned so the caller answers # it, rather than thrown, so the id stays available. # # Both fault shapes are matched by their own tag rather than by their SHAPE. An earlier # draft matched the invalid-annotation case as a bare non-empty list, and `params.name` - # being a JSON array — caller-controlled — reaches this `else` as exactly that. + # being a JSON array (caller-controlled) reaches this `else` as exactly that. {__MODULE__, :host_fault, _k, _r, _st} = fault -> fault {__MODULE__, :invalid_annotation, _tool, _detail} = invalid -> invalid _ -> [] @@ -1269,7 +1269,7 @@ if Code.ensure_loaded?(Plug) do # ONE ENTRY PER ANNOTATED PROPERTY, and a list rather than a map keyed by the case-folded # name. The key was the defect: `Map.put` dropped a sibling annotated with the same name in # another case and `Map.merge` let a nested one overwrite an outer one, so a property could - # be annotated, published to clients through `tools/list`, and never checked — silently, and + # be annotated, published to clients through `tools/list`, and never checked -- silently, and # with which of the two survived decided by map iteration order. # # A property path is unique by construction, so nothing here can be lost by another @@ -1315,7 +1315,7 @@ if Code.ensure_loaded?(Plug) do # # A property with NO declared type is left alone rather than refused. It cannot be judged # from the schema, and judging it on the caller's VALUE instead would make a caller who - # sends the wrong shape into a host fault — the wrong side of the trust boundary, which is + # sends the wrong shape into a host fault: the wrong side of the trust boundary, which is # the mistake this check exists to stop making in the other direction. @annotatable_types ~w(string integer boolean) diff --git a/lib/beam_mcp/transport/stdio.ex b/lib/beam_mcp/transport/stdio.ex index 4659b9e5..30da991a 100644 --- a/lib/beam_mcp/transport/stdio.ex +++ b/lib/beam_mcp/transport/stdio.ex @@ -11,7 +11,7 @@ defmodule BeamMCP.Transport.Stdio do loop continues; end of input ends it. Responses are always newline-delimited. A request may arrive under the older `Content-Length` - framing and is read, because a client that speaks it is not wrong to try — but nothing is + framing and is read, because a client that speaks it is not wrong to try, but nothing is written back in that form. Whoever can write to this transport already has the host's privileges, which is why the diff --git a/livebooks/connectome.livemd b/livebooks/connectome.livemd index e42ca7d0..d309ca9e 100644 --- a/livebooks/connectome.livemd +++ b/livebooks/connectome.livemd @@ -18,7 +18,7 @@ beam_mcp exports a composed MCP system's call graph twice -- **declared** (what happen, from the catalog and `:xref`) and **observed** (what did happen, from a telemetry span on the one dispatch site) -- and diffs the two into four classes. Every export is canonical JSON (`docs/connectome-canonical.md`): the same graph gives the same -bytes and the same hash — the digest the bytes name, SHA-256 here — whoever wrote it. +bytes and the same hash (the digest the bytes name, SHA-256 here), whoever wrote it. This notebook renders those exports and nothing else. It does not install `beam_mcp`, and no cell names it: a reader with only the JSON -- a reviewer, an auditor, a consumer on @@ -250,7 +250,7 @@ Kino.DataTable.new( ## Into Neo4j, without a fourth exporter The package stops at canonical JSON: its two other renderings (DOT, GraphML) are already the -largest surface no hash covers — the hash is over the canonical bytes alone — and a Cypher +largest surface no hash covers (the hash is over the canonical bytes alone), and a Cypher exporter would be a third such place for the next escaping defect, verifiable by nobody against the hash. A Neo4j user loads the canonical bytes directly with APOC's JSON loader -- one statement over the diff --git a/mix.exs b/mix.exs index e29b850f..56164e9a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule BeamMCP.MixProject do use Mix.Project - @version "0.9.0" + @version "0.10.0" @source_url "https://github.com/ScriptKittyOS/beam_mcp" # The oldest OTP this project supports. Mix has an `elixir:` key but none for OTP, so the diff --git a/test/beam_mcp/negotiation_test.exs b/test/beam_mcp/negotiation_test.exs index 3c963be0..2c379397 100644 --- a/test/beam_mcp/negotiation_test.exs +++ b/test/beam_mcp/negotiation_test.exs @@ -88,7 +88,7 @@ defmodule BeamMCP.NegotiationTest do ) end - describe "server/discover — mandatory in 2026-07-28" do + describe "server/discover: mandatory in 2026-07-28" do test "it exists and advertises the supported versions, capabilities and identity" do r = send_msg(%{"jsonrpc" => "2.0", "id" => 1, "method" => "server/discover"}) @@ -348,7 +348,7 @@ defmodule BeamMCP.NegotiationTest do end end - describe "ping — answered at legacy, absent at modern" do + describe "ping: answered at legacy, absent at modern" do test "a legacy ping is answered" do r = send_msg(%{"jsonrpc" => "2.0", "id" => 1, "method" => "ping"}) @@ -368,8 +368,8 @@ defmodule BeamMCP.NegotiationTest do assert r["result"] == %{}, "ping exists in 2025-11-25. This server advertises 2025-11-25 in " <> "server/discover and lists it in the -32022 `supported` payload, and the " <> - "specification tells a client to pick from that list and retry the request " <> - "— which produces this message. Refusing it refuses a revision we advertise." + "specification tells a client to pick from that list and retry the request, " <> + "which produces this message. Refusing it refuses a revision we advertise." end end @@ -414,7 +414,7 @@ defmodule BeamMCP.NegotiationTest do end end - describe "JSON-RPC batching — required in exactly one revision, and not ours" do + describe "JSON-RPC batching: required in exactly one revision, and not ours" do test "a batch is refused rather than processed" do batch = [ %{"jsonrpc" => "2.0", "id" => 1, "method" => "ping"}, diff --git a/test/beam_mcp/otp_floor_test.exs b/test/beam_mcp/otp_floor_test.exs index 8068699e..783e1020 100644 --- a/test/beam_mcp/otp_floor_test.exs +++ b/test/beam_mcp/otp_floor_test.exs @@ -11,7 +11,7 @@ defmodule BeamMCP.OTPFloorTest do This file demonstrates the raise and its message on this OTP (a synthetic below-floor string), and pins the floor number and the reason equal across `mix.exs` and the README. It does NOT - compile the package under a real below-floor OTP — none is installed here; 023's CI matrix + compile the package under a real below-floor OTP: none is installed here; 023's CI matrix floor leg runs the package on the floor release itself, and a real below-floor compile is named as a gap in the slice record. """ diff --git a/test/beam_mcp/public_api_census_test.exs b/test/beam_mcp/public_api_census_test.exs index 93ca8c6e..660ebec9 100644 --- a/test/beam_mcp/public_api_census_test.exs +++ b/test/beam_mcp/public_api_census_test.exs @@ -225,7 +225,7 @@ defmodule BeamMCP.PublicAPICensusTest do defp changelog(unreleased_body) do "# Changelog\n\n## [Unreleased]\n\n" <> unreleased_body <> - "\n\n## [0.5.0] — 2026-09-16\n\n### Changed — BREAKING: old\n\n- old text.\n" + "\n\n## [0.5.0] - 2026-09-16\n\n### Changed (BREAKING): old\n\n- old text.\n" end defp run(opts) do @@ -375,7 +375,7 @@ defmodule BeamMCP.PublicAPICensusTest do named = "### Removed\n\n- **Removed** `BeamMCP.Fixture.PublicAPI.plain/1`." breaking = - "### Changed — BREAKING: plain/1 goes\n\n- **Removed** `BeamMCP.Fixture.PublicAPI.plain/1` — a 0.x documented break at the minor.\n **How to tell whether you are affected:** you called it." + "### Changed (BREAKING): plain/1 goes\n\n- **Removed** `BeamMCP.Fixture.PublicAPI.plain/1`: a 0.x documented break at the minor.\n **How to tell whether you are affected:** you called it." # Silent; named but nothing else; named, first-time deprecated in the same change. assert checks(run(population: [@shape], baseline: gone.("removed_in=Unreleased"))) == [ @@ -420,7 +420,7 @@ defmodule BeamMCP.PublicAPICensusTest do run( population: [@shape], baseline: gone.("removed_in=Unreleased"), - changelog: String.replace(breaking, "BREAKING: ", "") + changelog: String.replace(breaking, " (BREAKING)", "") ) ) == [:removed_unjustified] @@ -443,7 +443,7 @@ defmodule BeamMCP.PublicAPICensusTest do # The phrase on a bullet naming a different entry does not count for this one. other = - "### Changed — BREAKING: shape goes\n\n- `t:BeamMCP.Fixture.PublicAPI.shape/0` — a 0.x documented break at the minor.\n- **Removed** `BeamMCP.Fixture.PublicAPI.plain/1`.\n **How to tell whether you are affected:** you called it." + "### Changed (BREAKING): shape goes\n\n- `t:BeamMCP.Fixture.PublicAPI.shape/0`: a 0.x documented break at the minor.\n- **Removed** `BeamMCP.Fixture.PublicAPI.plain/1`.\n **How to tell whether you are affected:** you called it." assert checks( run(population: [@shape], baseline: gone.("removed_in=Unreleased"), changelog: other) @@ -541,23 +541,23 @@ defmodule BeamMCP.PublicAPICensusTest do end test "the rule: a BREAKING heading in the Unreleased section carries the how-to-tell sentence" do - assert checks(run(changelog: "### Changed — BREAKING: something\n\n- a bullet.")) == [ + assert checks(run(changelog: "### Changed (BREAKING): something\n\n- a bullet.")) == [ :breaking_without_how_to_tell ] assert run( changelog: - "### Changed — BREAKING: something\n\n- a bullet. **How to tell whether you are affected:** thus." + "### Changed (BREAKING): something\n\n- a bullet. **How to tell whether you are affected:** thus." ) == [] end test "sections/1 reads the Unreleased section only, heading by heading, bullet by bullet" do text = - "# C\n\n## [Unreleased]\n\n### A\n\n- one\n continued\n- two\n\n### B — BREAKING\n\nprose\n\n- three\n\n## [0.5.0]\n\n### Z\n\n- zed\n" + "# C\n\n## [Unreleased]\n\n### A\n\n- one\n continued\n- two\n\n### B: BREAKING\n\nprose\n\n- three\n\n## [0.5.0]\n\n### Z\n\n- zed\n" assert [ {"### A", _, ["- one\n continued", "- two"]}, - {"### B — BREAKING", body, ["- three"]} + {"### B: BREAKING", body, ["- three"]} ] = PublicAPI.sections(text) assert body =~ "prose" diff --git a/test/beam_mcp/readme_claims_test.exs b/test/beam_mcp/readme_claims_test.exs index bbd65c15..0a9948ce 100644 --- a/test/beam_mcp/readme_claims_test.exs +++ b/test/beam_mcp/readme_claims_test.exs @@ -137,7 +137,7 @@ defmodule BeamMCP.ReadmeClaimsTest do describe "the dependency requirement the README hands a consumer" do test "it does not span the wire break this release documents" do - requirement = "~> 0.9.0" + requirement = "~> 0.10.0" claims("{:beam_mcp, \"#{requirement}\"}") version = Mix.Project.config()[:version] @@ -145,16 +145,20 @@ defmodule BeamMCP.ReadmeClaimsTest do assert Version.match?(version, requirement), "the README's requirement must admit the version being shipped" + refute Version.match?("0.9.0", requirement), + "0.9.0 is the minor before this one; 0.10.0 is a quiet minor that moved no public " <> + "entry, and the pin still stops at the current minor by the 0.x rule -- the next " <> + "minor is where the next documented break can be." + refute Version.match?("0.8.0", requirement), - "0.8.0 is the minor before this one; 0.9.0 adds the :server seam and the scheme " <> - "beside the signature and breaks nothing, and the pin still stops at the current " <> - "minor by the 0.x rule -- the next minor is where the next documented break can be." + "0.8.0 is two minors back; 0.9.0 added the :server seam and the scheme beside " <> + "the signature and broke nothing." refute Version.match?("0.7.0", requirement), - "0.7.0 is two minors back; 0.8.0 was a quiet minor that moved no public entry." + "0.7.0 is three minors back; 0.8.0 was a quiet minor that moved no public entry." refute Version.match?("0.6.0", requirement), - "0.6.0 is three minors back; 0.7.0 added the signer seam and broke nothing." + "0.6.0 is four minors back; 0.7.0 added the signer seam and broke nothing." refute Version.match?("0.5.0", requirement), "0.5.0 is on the far side of the break 0.6.0 documented in the exported " <> @@ -482,7 +486,7 @@ defmodule BeamMCP.ReadmeClaimsTest do end # The BEHAVIOUR behind these sentences is exercised in - # `test/beam_mcp/transport/http_test.exs`, under ":authorize_body/2 — the post-read hook, + # `test/beam_mcp/transport/http_test.exs`, under ":authorize_body/2: the post-read hook, # and the bytes it is handed": the byte-identity of the body, the init-time arity refusal, # allow-versus-refuse, the opacity of the refusal, and the absence of `connection: close`. # It is not duplicated here. Reaching across for that module's catalog fixture would give diff --git a/test/beam_mcp/threat_model_test.exs b/test/beam_mcp/threat_model_test.exs index 47062290..d2daa931 100644 --- a/test/beam_mcp/threat_model_test.exs +++ b/test/beam_mcp/threat_model_test.exs @@ -316,7 +316,7 @@ defmodule BeamMCP.ThreatModelTest do test "the reader sees a citation beside escaped quotes in the prose, and asks ExUnit which cited tests are live" do row = ~s(| **x** | REFUSED | says `"name"` and \\"quoted\\" | ) <> - ~s(`test/beam_mcp/threat_model_test.exs` "a name" "another" | — |) + ~s(`test/beam_mcp/threat_model_test.exs` "a name" "another" | none |) assert BeamMCP.Boundary.citations(row) == [{"test/beam_mcp/threat_model_test.exs", ["a name", "another"]}] diff --git a/test/beam_mcp/transport/http_test.exs b/test/beam_mcp/transport/http_test.exs index b33fd182..c36a3f01 100644 --- a/test/beam_mcp/transport/http_test.exs +++ b/test/beam_mcp/transport/http_test.exs @@ -268,7 +268,7 @@ defmodule BeamMCP.Transport.HTTPTest do end end - describe "MCP-Protocol-Version — the header that makes the stdio no-era path impossible here" do + describe "MCP-Protocol-Version: the header that makes the stdio no-era path impossible here" do test "a POST without the header is rejected" do # "Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header." conn = post(msg("tools/list"), [{"mcp-method", "tools/list"}]) @@ -336,7 +336,7 @@ defmodule BeamMCP.Transport.HTTPTest do end end - describe "Origin — MUST be validated to prevent DNS rebinding" do + describe "Origin: MUST be validated to prevent DNS rebinding" do test "a disallowed Origin gets 403" do o = opts(allowed_origins: ["https://good.example"]) @@ -743,7 +743,7 @@ defmodule BeamMCP.Transport.HTTPTest do # is the only `get_req_header` call site, and the `mcp-param-` sweep is the only other read # of `conn.req_headers`. Each of these dies under its own mutant; see logs/mutation.md. - test "Origin — a second, disallowed Origin is not ignored" do + test "Origin: a second, disallowed Origin is not ignored" do o = opts(allowed_origins: ["https://ok.example"]) body = msg("tools/list") @@ -755,7 +755,7 @@ defmodule BeamMCP.Transport.HTTPTest do ).status == 403 end - test "MCP-Protocol-Version — a second, unsupported version is not ignored" do + test "MCP-Protocol-Version: a second, unsupported version is not ignored" do body = msg("tools/list") conn = @@ -764,7 +764,7 @@ defmodule BeamMCP.Transport.HTTPTest do assert conn.status == 400 end - test "Mcp-Method — a second, disagreeing method is not ignored" do + test "Mcp-Method: a second, disagreeing method is not ignored" do me = self() o = opts(dispatch: fn n, a, _ -> send(me, {:dispatched, n}) && {:ok, a} end) body = msg("tools/list") @@ -781,7 +781,7 @@ defmodule BeamMCP.Transport.HTTPTest do refute_receive {:dispatched, _}, 50 end - test "Mcp-Name — a second, disagreeing name is not ignored" do + test "Mcp-Name: a second, disagreeing name is not ignored" do body = msg("tools/call", %{"params" => %{"name" => "echo", "arguments" => %{}}}) conn = @@ -796,7 +796,7 @@ defmodule BeamMCP.Transport.HTTPTest do assert body!(conn)["error"]["code"] == -32_020 end - test "Mcp-Param-{Name} — a second, disagreeing value is not ignored" do + test "Mcp-Param-{Name}: a second, disagreeing value is not ignored" do body = msg("tools/call", %{ "params" => %{"name" => "echo", "arguments" => %{"region" => "eu-west1"}} @@ -1557,7 +1557,7 @@ defmodule BeamMCP.Transport.HTTPTest do end end - describe ":authorize_body/2 — the post-read hook, and the bytes it is handed" do + describe ":authorize_body/2: the post-read hook, and the bytes it is handed" do # `authorize/1` runs before the body is read, which is what lets it refuse an # unauthenticated caller without buffering megabytes for them. The cost is that it cannot # see the body, so body-signature auth is structurally impossible through it. This hook is diff --git a/test/beam_mcp/will_not_implement_test.exs b/test/beam_mcp/will_not_implement_test.exs index 7f27e566..d902265b 100644 --- a/test/beam_mcp/will_not_implement_test.exs +++ b/test/beam_mcp/will_not_implement_test.exs @@ -71,14 +71,14 @@ defmodule BeamMCP.WillNotImplementTest do test "the README's count of entries is the page's row count, spelled as the README spells it" do n = length(rows(page())) assert n > 0 - # The README's whitespace folded as the page's is below, so a reflow at the em dash is - # read whole; the phrase is the README's own idiom ("boundary — twelve entries,"). + # The README's whitespace folded as the page's is below, so a reflow after the parenthesis + # is read whole; the phrase is the README's own idiom ("boundary (twelve entries,"). readme = File.read!(Path.join(@root, "README.md")) |> String.replace(~r/\s+/u, " ") word = Enum.at(@words, n - 1) assert word, "#{n} rows: more than this test spells; extend @words" - assert readme =~ "boundary — #{word} entries", - "the page has #{n} rows and the README does not say \"boundary — #{word} entries\" " <> + assert readme =~ "boundary (#{word} entries", + "the page has #{n} rows and the README does not say \"boundary (#{word} entries\" " <> "-- a row was added or removed and the README's count did not move with it" end @@ -95,7 +95,7 @@ defmodule BeamMCP.WillNotImplementTest do # `u`: the en dash is three bytes, and without it the class consumes one of them. A # range -- hyphen, en dash, em dash or minus sign, spaced or not, or "to"/"through" -- is # refused by name rather than read. - refute sentence =~ ~r/\d\s*[-–—−]\s*\d|\d (?:to|through) \d/u, + refute sentence =~ ~r/\d\s*[-–\x{2014}−]\s*\d|\d (?:to|through) \d/u, "the placement sentence writes a range; list each entry so the census can read it" placed = diff --git a/test/fixtures/wire/pre-017.json b/test/fixtures/wire/pre-017.json index 10951422..463518d5 100644 --- a/test/fixtures/wire/pre-017.json +++ b/test/fixtures/wire/pre-017.json @@ -7,7 +7,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -27,7 +27,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -50,7 +50,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -71,7 +71,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -95,7 +95,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -116,7 +116,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -140,7 +140,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -171,7 +171,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -204,7 +204,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", @@ -251,7 +251,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.9.0" + "version": "0.10.0" } }, "cacheScope": "private", diff --git a/tools/sbom.sh b/tools/sbom.sh new file mode 100755 index 00000000..22a1ab7e --- /dev/null +++ b/tools/sbom.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The release's software bill of materials (CycloneDX 1.6, JSON), generated OUTSIDE mix.exs. +# +# tools/sbom.sh +# +# WHY OUTSIDE mix.exs. The owner's constraint for the SBOM (2026-09-17): the EEF's tool, not a +# dependency of this package; nothing added to mix.lock just to have a file; the file attached at +# release, not kept in the tree. So this runs the EEF's self-contained binary (erlef/mix_sbom, +# the Hex package `sbom`), pinned by version and by the SHA-256 GitHub records for the release +# asset, against a `git archive` of the ref (the same tree tools/release_tarball.sh packages), +# after `mix deps.get` there, so the lock's resolved versions are what it reads. `--only prod`: +# the runtime dependency set a host inherits (plug and bandit included, though optional in +# mix.exs -- the tool has no notion of an optional dependency and marks them required). +# +# ONE WORKAROUND, NAMED (G-087). mix_sbom 0.11.0 crashes on the `tools: :optional` entry in +# mix.exs's `extra_applications` (FunctionClauseError in its normalize_dep/1; Mix documents +# the `{app, :optional}` form). In the scratch copy only, that one entry is rewritten to +# `:tools` before the tool reads it; the shipped mix.exs is untouched and the component list is +# the same. The step refuses to run if the line is not the one it expects, so a changed mix.exs +# is noticed rather than silently mis-read. Remove it when a mix_sbom release reads the form. +# +# Not byte-reproducible by design: CycloneDX gives every document a fresh serialNumber and a +# timestamp. What binds it is the attestation over the tarball's digest (provenance.yml). +# Needs bash, git, curl, sha256sum, and Elixir/Mix for `mix deps.get`. +set -euo pipefail +ref=${1:?ref (a tag or commit)}; out=${2:?output path} +case "$out" in /*) ;; *) out="$PWD/$out" ;; esac + +version=0.11.0 +digest=db1982c8599f9383c48e6bb61cd4dc7305d2afdf48273568839b7f1dc0d572de + +root=$(git rev-parse --show-toplevel) +work=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-sbom.XXXXXX"); trap 'rm -rf "$work"' EXIT + +bin="$work/mix_sbom" +curl -fsSL -o "$bin" "https://github.com/erlef/mix_sbom/releases/download/v${version}/mix_sbom_Linux_X64" +echo "${digest} ${bin}" | sha256sum -c - >/dev/null \ + || { echo "mix_sbom ${version}: the binary's sha256 is not the release asset's" >&2; exit 1; } +chmod +x "$bin" + +mkdir "$work/src" +git -C "$root" -c tar.umask=022 archive --format=tar "$ref" | tar -x -C "$work/src" + +expected=' [extra_applications: [:logger, :crypto, tools: :optional]]' +grep -qxF -- "$expected" "$work/src/mix.exs" \ + || { echo "mix.exs no longer has the extra_applications line the G-087 workaround rewrites" >&2; exit 1; } +sed -i 's/^ \[extra_applications: \[:logger, :crypto, tools: :optional\]\]$/ [extra_applications: [:logger, :crypto, :tools]]/' "$work/src/mix.exs" + +(cd "$work/src" && mix deps.get >/dev/null) +"$bin" cyclonedx --only prod --format json --schema 1.6 --force --output "$out" "$work/src" >/dev/null + +components=$(grep -o '"bom-ref"' "$out" | wc -l | tr -d ' ') +echo "sbom ${out}: CycloneDX 1.6, ${components} bom-refs, from ${ref} = $(git -C "$root" rev-parse "${ref}^{commit}") (mix_sbom ${version})"