Skip to content

0.16.0 release candidate: replicas, skills, steering and audit fixes (draft) - #203

Merged
tomtom215 merged 99 commits into
mainfrom
claude/affectionate-gauss-5yn4f5
Nov 9, 2026
Merged

tomtom215 merged 99 commits into
mainfrom
claude/affectionate-gauss-5yn4f5

Conversation

@tomtom215

@tomtom215 tomtom215 commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

What this changes

The 0.16.0 line as it stands at 45ac1ea9: 99 commits, 680 files, +67,245 / −1,730 against main (git diff --shortstat origin/main...HEAD). It merges cleanly (git merge-tree, and GitHub reports the merge state as clean). This is a draft. Nothing is tagged, and nothing here claims the release is ready. CI has run five times on it; the fifth, on this head, is green (below). The user-facing summary is the [Unreleased] section of CHANGELOG.md (Highlights, Breaking Changes, Behaviour Changes, Known Limitations); the migration for each breaking item is book/src/reference/upgrading/0.15-to-0.16.md.

Work in this stretch, by area (the CHANGELOG has the detail):

  • Deferred audit items: F14 (a streaming send whose executor panics ends with the failed frame), E-F15 (AuthFailureThrottle::with_shared_counter, and the PostgreSQL counter's sweep scoped by window width), E-F25 (b) LISTEN/NOTIFY wake on log appends and (c) a read pool that was measured and not built (ADR 0057), parked_task_ttl (ADR 0056), B-11 option 2 (ClientBuilder::with_blocking_over_stream).
  • New features: Agent Skills in the tool loop (ADR 0035, skills feature, no script is ever run) and steering a running task (ADR 0036, opt-in).
  • Behaviour changes a client can notice: an empty final model reply fails the task with a failure class (a reply cut at the output limit still completes and is marked a2a-rust.com/finish-reason: length); a ListTasks page token no store issued is InvalidParams in all five stores; a NUL character in any field of a SendMessage is InvalidParams. Closing the page-token item found a leak: a malformed token reached PostgreSQL's ::timestamp cast and came back as -32603 carrying the database's own message.
  • Bounds: the tool loop caps calls per model reply, bounds the trace line to 16 KiB, and reads provider usage leniently.
  • Two adversarial campaign reports (live models, hostile peers) under docs/audits/2026-11-07-campaign-wave2/, with their scripts under scripts/live/campaign/. They are data: each finding that was acted on was re-checked against the source first, and two of the hostile agent's and one of the live agent's were wrong or incomplete on re-checking.

An AI assistant (Claude Code) wrote and verified much of this under the maintainer's direction (see PROVENANCE.md). Every commit is authored by and signed off by the human author.

How it was verified

All commands are in docs/handoff.md (Now, Verified), with the figures and the commit each ran on.

  • CI, first pass on e0d2bee2: 83 check runs, 71 passed, 5 failed, 7 skipped. The five failures and their causes are in the PR comment of that name: PostgreSQL integration (a runner script flagged a module that held no PostgreSQL test), both Windows Test jobs (two tests assumed Unix paths), CodeQL, and the mutation-label gate.
  • CI, second pass on 6eca972b: 83 check runs, 74 passed, 2 failed, 7 skipped: every Test job passed; the failures were CodeQL (4 alerts) and the mutation-label gate.
  • CI, third pass on a647094e: 83 check runs, 74 passed, 2 failed, 7 skipped. Every Test job passed. CodeQL reported 1 alert (06-manifest-lint.py:73, which prints a Kubernetes Secret reference's name and key, not a secret); the five model_proxy.py alerts had cleared once 1da23c01 rewrote the proxy's checks into the forms CodeQL's own query source recognises (read as text; running CodeQL here was refused).
  • CI, fourth pass on 432c0d02: 132 check runs, 121 passed, 2 failed, 9 skipped. CodeQL passed (the maintainer dismissed alert 205); the mutation run passed; Test (stable, windows-latest) failed one flaky test, a2a-protocol-client discovery::fleet_tests::a_zero_draw_leaves_the_lifetime_whole (left: 2, right: 1): the counting server's Date has one-second resolution, so a response arrives up to a second old (RFC 9111 §4.2.3), and the test left a 1 s margin. Reproduced on Linux under load (2 of 600 runs; 5 of 5 with an injected old Date; 0 of 400 on a quiet machine); fixed in d3269f4f (a Date ahead of now, an age of zero), which passes 2400 of 2400 under the same load. The other failure was the stale gate result of the run the push started before the label was added.
  • CI, fifth pass on 45ac1ea9 (this head), finished green: 128 check runs, 122 passed, 6 skipped, 0 failed (the skipped ones are the schedule-only mutation jobs, the benchmark run, the ITK baseline comparison, the nightly canary and the codecov.yml check). The CI run (ci.yml) concluded success: Test on Ubuntu, macOS and Windows for stable and 1.94 (Windows stable at 115 minutes) and PostgreSQL integration all passed; CodeQL passed; the mutation run (mutants.yml) concluded success: all 46 incremental shards and the aggregator succeeded, so no survivors, failures or timeouts across the whole diff (the 40 server shards took 54 to 85 minutes, against the job's 180-minute timeout; a comment above it in mutants.yml still says 75). All six CodeQL review threads are resolved.
  • Tests, one package at a time: cargo nextest run --profile ci --no-tests=pass -p <member> with RUSTFLAGS=-D warnings and live PostgreSQL, for all 26 workspace members with default features and with --no-default-features, for the 22 unpublished members with --all-features, and with --features signing, tracing, websocket and grpc on the members that define each: 93 runs, every one exit 0.
  • PostgreSQL suites: python3 scripts/run_postgres_suites.py against PostgreSQL 16 locally: exit 0 (14 test targets, 21 library modules); the two steps CI skipped after its failure also pass locally.
  • Static and build gates: workspace clippy -D warnings (all features, defaults, x86_64-pc-windows-gnu), cargo doc, cargo package, cargo deny check, cargo semver-checks against v0.15.0, the book build and link check, cargo hack clippy --each-feature, nightly clippy and the docs.rs build, the SLIMRPC binding, the dogfood example, scripts/preflight_all.py (12 of 13 rows passed on the first run; the 13th, the promtool gate, then passed: all five steps of observability.yml:promtool, exit 0).
  • Mutation, --in-diff, RUSTFLAGS unset (local): run per area on the day's diffs; survivors were killed or proved equivalent in writing (ADR 0006).

Not covered: two surviving mutants outside this diff are open (handler/shutdown/in_flight.rs:236, a2a-protocol-client/src/https_client.rs:116); the mutation run covers the diff only. Two reaper tests (handler::claims::reaper::tests) failed once in a single local run against a long-used database, and passed in ten later runs and in CI: unexplained. docs/handoff.md was last updated at the fourth pass and says the fifth is still to come; it is correct but one pass behind.

Open, for the maintainer (all in the handoff and CHANGELOG): the Known Limitations (a cancel racing the abrupt death of the owning replica; authorization checked after dispatch; six live-model findings), STABILITY.md §1 (false at a tag), the adopter walk-through's open low-severity items, the stale timeout comment in mutants.yml, marking the PR ready, and the commits' Unverified status (none is signed).

Checklist

  • Every commit is signed off (git commit -s) by a human git author — scripts/check_dco.sh origin/main HEAD: "All commits carry a matching sign-off from a human author", exit 0; the DCO check passed in CI
  • SPDX header on every new file — a scan of the first five lines of the 213 new source, script, config and document files found none without one; the other 286 new files are recorded run output (logs, JSON) and licence texts (my scan, not a repository gate); the one file added since, fleet_tests/jitter.rs, has its header
  • No file exceeds 500 lines — scripts/check_file_lengths.sh exit 0 (it reports 82 of 2026 tracked source files over 500 lines, all recorded in the ratchet); the Static checks job passed in CI
  • cargo fmt --all passes — cargo fmt --all -- --check, exit 0
  • cargo clippy --workspace --all-targets -- -D warnings passes — CI's Clippy jobs are not among the failures or skips of the fifth pass on 45ac1ea9, so they passed (by exclusion)
  • cargo test --workspace passes — CI's Test jobs on Ubuntu, macOS and Windows (stable and 1.94) and PostgreSQL integration passed on 45ac1ea9
  • cargo doc --workspace --no-deps passes without warnings — as for clippy: not among the failures or skips on 45ac1ea9
  • New public types/functions have doc comments — the crate-level deny(missing_docs) attribute is set in the lib.rs of all five published crates, and they compile in CI
  • New code has tests — each fix's test was written and seen failing first, but this is not measured as a property of the whole branch
  • cargo mutants --test-tool=nextest --all-features shows zero surviving mutants for changed files — the incremental run over the whole diff on 45ac1ea9 succeeded in all 46 shards and its aggregator (no survivors); the two survivors still open are outside the diff
  • CHANGELOG.md updated if the change is user-visible
  • ADR created or updated if an architectural decision was made or revised — ADR 0056 and 0057 added; 0011, 0035 and 0036 updated

…ed counter no longer sweeps another width's rows (E-F15)

AuthFailureThrottle counted per process, so a fleet gave a peer N times
max_failures guesses a window. `with_shared_counter` takes the same
RateLimitCounter the rate limiter takes. A failure is counted in the counter
under `authfail:<peer>` when the call ends, and the throttle keeps the larger
of its own count and the total the counter reports. The counter is not read
on a call's way in, so a call with a valid credential costs no round trip;
the bound after the fleet reaches the limit is one more failed call per
replica that has not yet failed (a test pins it), and a counter that fails
leaves per-process throttling.

Found while designing it, and measured in plain SQL before any Rust: the
Postgres counter's sweep deleted by the *calling* window width across the
whole table, so a 60 s sweep removed a live 300 s row (a count of 3). The
rate limiter (60 s) and this throttle (300 s) share the table by default. Rows
now record `window_secs` (added to an earlier table by a guarded DO block, and
by the count's ON CONFLICT for a row that has none), a sweep removes its own
width's stale rows and rows that record none, and leaves another width's.
Six hand mutants of the SQL, baseline first, each killed by the intended test;
the one that did not compile was re-run in a compiling form.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…s with the failed state (F14, audit D-03 d)

The panic already stored `failed`, pushed it and logged it, but the stream a
SendStreamingMessage client reads is fed by the executor's queue, which the
unwind closes with the last state still `working`: the stream just ended, which
is also how a dropped connection looks. Reproduced first, on both runtime
flavours, as the frames [Task(submitted), StatusUpdate(working)] and then
nothing (the two new streaming tests failed on the unchanged code).

The reader remembers the last task state a frame reported. When its queue
closes with no terminal state delivered and the last state not interrupted
(an executor that returns at input-required ends its stream rightly), it asks
a hook, once, for the task's terminal state as stored, and sends that as the
last frame; a terminal answer ends the stream like any other terminal state
(and the optional final snapshot may follow). The handler's hook polls the
store every 20 ms for executor_drain_timeout + subscribe_replay_catchup, the
time the processor may take to decide plus the store's write, and gives up
with the stream ending as before. Streams built without the hook (subscribe,
replay) are unchanged.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…on 2, opt-in)

`ClientBuilder::with_blocking_over_stream(true)` makes `send_message` to an
agent whose card declares streaming a SendStreamingMessage, read until the
task is terminal or interrupted (or the agent answers with one Message), and
answered with the task as the agent stores it: a GetTask with the send's
history length, repeated over 3.175 s while the store is one event behind the
stream, and an error rather than an older state if it never gets there. A
stream's 30 s keep-alive comments keep a load balancer's connection open
where a blocking send's silence does not.

Not a client-side fold of the events: the server's blocking answer applies
rules for artifact chunks, history and a task finished by another writer, and
a second implementation of them would disagree somewhere. Off by default; a
client with no card, a send with return_immediately and a card that does not
declare streaming are sent as before. send_timeout bounds the whole send.

Against a real server through a TCP proxy that closes a connection after 600 ms
of silence: the plain blocking send is cut on both bindings (the defect, from
audit B-11), the carried one is not, and for a task that completes, fails and
is interrupted its answer equals a plain blocking send's, on JSON-RPC and
HTTP+JSON.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
… default

cargo-mutants lists `replace ClientConfig::with_blocking_over_stream -> Self with
Default::default()` for the in-diff set, and `ClientConfig` has a Default, so the
mutant is viable; the setter had no test of its own (the builder's setter is
exercised through the client tests, the config's was not). It joins
every_setter_sets_its_field, and the off default is asserted for both
constructors.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The throttling page's "Several replicas" section said a shared counter was not
available for AuthFailureThrottle; it now describes with_shared_counter, what it
costs (no read on a call's way in; one more failed call per replica after the
limit is reached) and what it leaves open. The rate-limiting page says why rows
record their window width. The load-balancer page has the fourth remedy.

The handoff described pull request #197 as open. Its old Now block is kept
verbatim at the end of handoff-history.md and the new one says what is true on
2026-11-07, what is in flight and what was not verified.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The four 0.16.0 tarballs carried the manifest's license = "Apache-2.0" and no
licence text: LICENSE and NOTICE sat only at the repository root, and Cargo
packages a crate's own directory. Found by unpacking the tarballs
cargo package wrote (docs/audits/2026-11-07-adopter-walkthrough).

Each of types, client, server and sdk now holds a copy of both, and
scripts/check_package_excludes.py (CI static checks) fails when a publishable
crate lacks one or holds one that differs. Red on the tree without the copies
(8 findings, exit 1), green with them, and red again with one byte appended to
one copy.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…e README and the crate map

0.16.0 turned tls-rustls on by default in a2a-protocol-server, and three places
went on saying it was the client's and the SDK's alone: the Installation page's
Feature Flags paragraph and its server table row, the README's TLS row, and the
crate-map diagram's server box (default: tracing, otel). Found by reading the
pages against grep -n '^default' crates/*/Cargo.toml.

scripts/check_feature_tables.py (CI static checks) now also checks where the
defaults are said and not only tabulated: a Description cell that says 'on by
default' against the manifest, in both directions; the Installation page's
'on by default for' sentences, per feature; and the diagram's default: lists.
Red on the unfixed tree with exactly those three findings, green after. The
diagram is re-rendered with scripts/render_book_diagrams.sh; only the server
node's geometry changed.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…s no longer offered

Two kinds of stale prose a stranger meets on the first page.

The README's conformance badge said 83/114 MUST with 5 failing, and the Project
Status paragraph under it, and docs/official-tck-findings.md, say 85 of 115 with
2 failing (measured 2026-11-06). The badge now says 85/115, 2 failing.

serve() and serve_with_addr() are deprecated since 0.16.0 (neither can be
stopped), and the introduction's feature list, the README's Server startup row,
the server crate's key-types table, the API reference's Dispatcher row and the
SDK README's prelude list still offered serve() as the way to start a listener.
They now lead with Server::bind and serve_with_shutdown.

scripts/check_doc_versions.py (CI static checks) gains two assertions: the
badge's counts equal the paragraph's, and a table row, list item or paragraph of
current prose that names serve() or serve_with_addr() in backticks says
'deprecated'. Run against the unfixed pages it reports the badge and four
serve() offers; against the fixed tree it is green.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
A reader with a2a-protocol-server = "0.16" and the default features gets
'unresolved import' on the first block of nine book pages that use
PostgresTaskStore and its siblings, WebSocketDispatcher, tool_loop or
auth::jwt and never name the feature: the three tool-loop sub-pages, the
WebSocket dispatcher page, the authorization page, the custom-store page and
the three horizontal-scaling pages that use PostgreSQL. The book's own doctests
(book-tests) turn every feature on, so nothing noticed. Found by compiling
every block of the README and the book against the default features, outside
the workspace (scripts/live/adopter/03-book-sweep.sh): 66 of 340 blocks failed,
15 of them on these nine pages (the rest are on pages that do name a feature,
on migration guides, or on the page for an unpublished crate).

Each page now opens with a note naming the feature and the two dependency
lines. scripts/check_feature_tables.py (CI static checks) gains the assertion:
a page whose compiled Rust blocks use an item that exists only under a feature
names that feature in backticks or quotes. Red before the notes with exactly
these nine pages, green after; the migration guides, ADR summaries and pitfalls
pages are not read.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
- first-agent.md: typing Steps 1 to 3 into src/main.rs as the page says repeated
  use a2a_protocol_sdk::prelude::* three times and rustc printed two unused
  import warnings. Steps 2 and 3 now hide their copy of the import and the page
  says the one from Step 1 serves all three. The adopter script
  (scripts/live/adopter/02-book-getting-started.sh) now fails on a warning:
  red before (evidence/02-book-getting-started.log, two warnings), green after
  (02-book-getting-started-after-fix.log, none, Result: Completed, Answer: 100).
- README.md, Command line: the block ran a2a against 127.0.0.1:3111 without
  saying what listens there; it now starts echo-agent in server-only mode on that
  port first.
- tools/a2a-cli/README.md and book/src/reference/cli.md: exit code 1 was said to
  print one error line and a second JSON line. stderr also carries the SDK's own
  WARN or ERROR line for the same failure, before them (the CLI installs the
  SDK's subscriber); the table now says to read the last line or two.
- hello-agent README and book page quoted the Rust Display text of the error for
  a message with 'content' in place of 'parts' (invalid params: message must
  contain...). The wire carries -32602 and 'message must contain at least one
  part' (field message.parts); that is what the pages now quote.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
TaskStoreConfig::with_parked_task_ttl(Duration) makes a task that has sat
parked (input-required, auth-required) for the TTL evictable like a finished
one in the in-memory store: the TTL pass takes it, the capacity pass takes it
with the finished tasks, a full store of expired ones takes a new task, and a
tenant partition's shared budget may free its slot. Counted
(InMemoryTaskStore::parked_evictions) and logged. Unset by default.

RetentionPolicy::with_parked_max_age(Duration) adds a parked sweep to
purge_expired on all four SQL stores, in the same batches and under the same
max_batches, counted in the new PurgeReport::parked_tasks_deleted. It deletes
(a store cannot write the event, push and audit record a cancel is), skips a
task with a live turn claim, and raises the idempotency-key age to at least the
parked age. ADR 0011 amended; book pages updated. Unset by default: nothing
changes for an operator who sets neither.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…r programs use

The tool-loop page's minimal agent uses tokio (#[tokio::main], hidden from the
rendered page) and serde_json (the Tool trait's parameters()), and the page's
only dependency line was the SDK with features = ["tool-loop"]: a reader who
typed that line and the program got four compile errors. The SQLite and
PostgreSQL store pages' programs use #[tokio::main] over a dependency block that
named only the server crate. Found by building the tool-loop page's agent from
the page's own blocks outside the repository
(scripts/live/adopter/05-tool-loop-page.sh, attempt 1).

The three blocks now list tokio (and serde_json). scripts/check_doc_versions.py
(CI static checks) gains the assertion: where a page shows a [dependencies]
block naming an a2a crate and a program with a fn main (hidden lines included),
every one of tokio, serde_json, serde, uuid, futures_util, tracing_subscriber,
hyper, axum and tonic the program uses is named in a toml block of the page.
Red before the edit with exactly these three pages (four findings), green after.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
Scripts under scripts/live/adopter (each says what it needs in its header and in
the README beside it) and the write-ups for packaging, the README and book, the
examples, the CLI and documentation hygiene, with the raw output of the runs in
evidence/. Not part of any CI gate: the checks that became gates are in the
fix commits.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The README-figure, deprecated-entry and dependency-block assertions each have
inputs with a value either side of every boundary (a count one off in the
badge, a table row that says deprecated next to one that does not, a crate
declared and not declared, a fragment, an ignored block, a comment, a
migration guide, a .rs file). CI runs the self-test before the gate, as
check_net_copies.py does.

14 hand mutants of the new functions (scripts/live/adopter/hand_mutants_doc_versions.py,
one edit each to a copy of the file, after the unmutated copy passed): 14 killed.
The first run killed 12 of 15 and found three gaps (a badge-only README, the
dependency check's history and non-Markdown skips) and one no-op, the unhide of
hidden lines, which matching on the raw text made unnecessary and which is gone.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
… and observability runs

The live-model write-up (llama.cpp b12189 with Qwen3.5-2B and Gemma 4 E2B; a refusal; a
truncated and an empty answer, M-1; twelve scripted malformed replies; a client killed
mid-stream), the deployment write-up (the process, the pinned digests, compose and manifests,
the observability stack up against two agents, a PostgreSQL-backed agent and a scripted
model, with the repository's own panel checker and screenshots), the MSRV 1.94 build, the
book's cargo install, and the index with the ranked findings and the decisions needed.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…udit E-F25 (b))

TaskStore::watch_event_log(&TaskId) -> Option<LogWatch> is a defaulted method
(None: the stream polls on its timer alone, as before); AuditedTaskStore,
TracedTaskStore and EncryptedTaskStore forward it. The reattach hook creates a
watch before each read of the store and waits on it as well as on its timer,
reporting the cause (Metrics::on_subscribe_wake: notification, resync,
timeout; OTel counter a2a.server.subscribe.wakes). A wake never makes a stream
read faster than once an interval, so an active task does not cost a stream
more than polling did.

PostgresTaskStore and TenantAwarePostgresTaskStore opt in with
with_log_notifications(LogListen): the append becomes a data-modifying CTE that
runs pg_notify in the same statement (statement_count unchanged at 8 and 11;
rows_affected still says nothing was written), the payload is a bounded
length-prefixed key of partition and task (an id that does not fit is polled),
and one lazily started listener per store wakes the waiters, every one of them
after a reconnect. LogListen::Direct(url) is the halt store's answer for a
pooler in transaction mode. Without the opt-in the append is the statement it
was.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…ll build, the finished genai run, corrections

The all-features cargo check of the four unpacked tarballs (exit 0, 5 m 35 s), the
genai-agent demo's result with Gemma 4 E2B (exit 0 in 2,275 s), the corrected
statements of what ran and what did not (the packaging write-up had described a
sweep that was not run; the examples write-up miscounted what CI covers), the
README of the scripts directory completed, the doc gates rerun after the fixes
(13 of 13 exit 0).

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…hat was not verified

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…deployment checks)

Eleven commits from a worktree agent that worked as a stranger would, outside the
workspace, from the packaged crates. Ships LICENSE and NOTICE in each published
crate (none of the four tarballs had them: re-checked here with
`cargo package --list`), says which Cargo feature nine pages need, corrects
the TCK badge, the tls-rustls default and the serve() offers, and adds the
walk-through under docs/audits/2026-11-07-adopter-walkthrough/ with its scripts.
No Rust source changed.
… a lock test for the parked sweep

ADR 0057 (the wake built; the read pool measured, text to follow), the
streaming, PgBouncer, configuration and metrics pages, the live replica's
opt-in environment (A2A_LOG_NOTIFY, A2A_POOL_SIZE) and scripts/live/
subscribe_idle_live.py. Test modules moved to files the file-length and
pg_retry gates treat as tests (retention/tests, eviction/tests,
store/tests/pg_scratch, log_notify/tests); the retention sweep's helper for
the announcing append moved beside the SQL. A PostgreSQL test that the parked
sweep skips a row another transaction holds, not waits for it.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…nd test; the tenant append as one query

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…run with it miscounts unviable

A first local run on this session's diff read 8 caught, 31 unviable, 0 missed
for the server and 12, 25, 0 for the client. Mutants that leave a parameter
unused fail to build under -D warnings and are reported unviable, but build in
CI (mutants.yml sets no RUSTFLAGS) and must be killed. The unviable ones are
being re-run without RUSTFLAGS; the lesson is recorded so the next session does
not repeat the first run.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…ripts (ADR 0035)

A feature-gated `skills` module (off by default, implies `tool-loop`, no new
dependency): SkillCatalog::discover reads SKILL.md directories leniently and
returns a catalog plus diagnostics, never panicking and never refusing to
start; the frontmatter is read by an in-tree reader of the YAML subset the
specification's reference parser accepts, after a size check; the catalog goes
in the system prompt through ToolLoopExecutor::with_skills, with an
activate_skill tool whose name is an enum of the valid names and a confined
read_skill_file for references/ and assets/. scripts/ are listed and never
run; allowed-tools is recorded and never acted on; a project directory is not
read until marked trusted and never shadows an operator skill.

The hook into tool_loop is three field visibilities and one new file.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
A clippy and a nextest leg of the server crate with --features skills, the
two prove_gates_fail.sh injections they need (so --list does not stop on a gate
without one), and the skill_frontmatter fuzz target with its matrix entry,
README rows and the one lockfile edge the feature adds to fuzz/Cargo.lock
(server -> httpdate, through tool-loop; the package was already there).

Signed-off-by: Tom F. <tomf@tomtomtech.net>
Six scenarios against the model that obeys every CALL line it reads: a
repeated payment planted in a trusted project's skill runs once, differing
ones run to the turn bound (asserted as a limit), allowed-tools gives a skill
no tool, a project skill cannot take an operator skill's name, an untrusted
project directory is not read, and a skill's text cannot read outside the
skill or return a script. The SDK's skills feature now implies the SDK's
tool-loop feature, as the server's does, so the harness's gate holds.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The ADR is rewritten as Accepted (2026-11-07, moved from 0.17.0 into 0.16.0)
with the specification as read that day, the decisions the 0.17.0 text left
open (the YAML frontmatter read in-tree, the caps, allowed-tools never used,
no hot reload), what was run, what was built and what was not. A book page
shows the whole path (its Rust blocks compile), the API reference and ADR index
list the module, and the injection-containment document gains the skills
scenarios and what a trusted project directory means.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
Four commits from a worktree agent: the feature-gated skills module (no new
dependency, an in-tree reader of the YAML subset the specification's reference
parser accepts), ToolLoopExecutor::with_skills, the confined read_skill_file
tool, the injection-containment scenarios, CI steps for the feature and the book
page. ADR 0035 moves from Proposed to Accepted and from 0.17.0 into 0.16.0 on
the maintainer's instruction of 2026-11-07.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…line ratchet

config.rs (516 lines) keeps the struct and defaults, and the with_* setters move
to config/setters.rs; send_over_stream_tests.rs (687 lines) becomes a directory
module of a support file and four test files. No test changed: 21 B-11 tests
before and after (grep of the run), and the config setter test passes.
check_file_lengths.sh passes after git add, which it requires: it counts tracked
files only.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The control map's ASI-MODEL row cites the six new skills scenarios and says what
a skill's text can and cannot do; it is generated from controls.toml and
check_compliance_map.py --check reports all 337 cited tests present. The
changelog says plainly that the YAML reader is in-tree, against the letter of
the dependency rule, as a decision the maintainer may overrule. The handoff
records that skills' mutation numbers come from an extraction crate, and that
target/mutants is 9 GB after a few runs.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The listener ends with the last store clone (log_notify Drop), and the key sweep
still runs after the parked sweep on both PostgreSQL stores: batches, counts and
the out-of-budget report (expire_keys and the guard that skips it).

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…s Known Limitations

The handoff now says what the live agent found (15), what it fixed (four) and
what was fixed here after re-checking (four more, `17e124fb`), that one of its
findings is not a defect, and what it did not run. The three bounds added to
the tool loop are listed among the decisions made for the maintainer. The six
findings that are left go into Known Limitations, marked as reproduced by the
agent and not all re-run. Its worktree is still locked by the harness, so the
reachability gate's false findings remain until it is released.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…try gate, with reasons

check_otel_wiring.py, run on the merged tree, failed on three `fn main`s the
campaign agents added (the hostile client and target, the live-model client):
none starts Telemetry and none was listed. They are probes and a deliberately
misbehaving target, read for their answers and timings, as the delegation and
replica live scripts beside them are; each gets an entry with its reason, as the
gate asks. The gate's self-test passes and the tree reads 18 exempt, 0 problems.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The exploratory question about a stream whose executor returns Ok with no
terminal state is partly covered by the hostile agent's h7_zombie.py (50
subscribers held, a cancel works) and states no hold time; say so, not that it
is unfinished.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…d with its proof

The mutation run over everything changed since a746210 scored the Unix
identity() (device and inode) 4 mutants caught, and the non-Unix one I added
for the Windows clippy fix (fed36c9) 3 missed: (0,1), (1,0) and (1,1). They
are not weak tests. That function is compiled only off Unix, so no Linux
mutation run builds it, and its only use is `identity(&opened) !=
identity(&named)`: a constant is equal to itself, so any constant behaves as
(0, 0) does. #[mutants::skip] with that proof, as ADR 0006 says for equivalent
mutants and net/client.rs does; the existing swap test
(a_file_other_than_the_one_at_the_path_is_caught_after_the_open) kills the Unix
ones.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…rently

A client or agent that relied on an empty page for a made-up page token, on a
NUL being accepted, or on an empty model answer completing a task would have
found these only under Behaviour Changes. One bullet under Action required.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…of it are tested

The mutation run over the day's diff missed `<` -> `<=` in whole(), my float-
to-u64 helper for a provider's token count: its bound was 1.8e19, a number I
picked near u64::MAX, so nothing tested the edge. The bound is now the real one,
2^64, the first float no u64 holds, where a cast saturates to u64::MAX (a count
the provider never reported). The test gains the largest whole float below
2^64 (kept) and 2^64 itself (no usage), and -0.0. Applied by hand with the
baseline first, `<=` makes 2^64 read as 18446744073709551615 and the test fails;
the file restored byte for byte (same SHA-256 prefix).

Signed-off-by: Tom F. <tomf@tomtomtech.net>
The last survivor of the mutation run over the day's diff: `16 * 1024` ->
`16 + 1024` in TRACE_LINE_LIMIT, because every test used the constant by name and
so passed for any value. A test now pins the documented 16,384 and keeps a
5,000-byte line whole. Baseline first, then the mutant by hand: it fails; the file
restored byte for byte (same SHA-256 prefix).

The run's total, from `cargo mutants --in-diff` over everything changed since
a746210 in the server crate with RUSTFLAGS unset: 94 mutants, 85 caught, 4
unviable, 5 missed, 0 timeouts. The five: three equivalent mutants of the
non-Unix identity() (skipped with a proof, 797002c), `<` to `<=` in whole()
(a4f4c7c) and this one. The types crate: 3 mutants, 2 caught, 1 unviable.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…l-features doc build

Moving charge_usage and parse_arguments to run_support.rs dropped run.rs's
TOKENS_METER import, and `with_budget`'s docs linked to it by name.
`cargo doc --workspace` and `--all-features` failed on it with the tool-loop
feature on; each crate's own default-features doc build, which does not compile
the tool loop, passed. The link now names the item by path. Both builds exit 0
with RUSTDOCFLAGS=-D warnings after.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…ase notes did not name

`cargo semver-checks --baseline-rev v0.15.0 --release-type patch` over the four
published crates (all features) lists every difference from 0.15.0. Reconciled
with the CHANGELOG's Breaking Changes and the upgrade guide, every item is already
documented but two: CardFetchOptions (it keeps an Arc<rustls::ClientConfig>) and
AuthFailureThrottle (its optional shared counter, audit E-F15, is an
Arc<dyn RateLimitCounter>) are no longer UnwindSafe or RefUnwindSafe. Both are
added beside the TlsConfig and ClientConfig entry and in the upgrade guide.

The tool's own count: types 13 checks failing in two lints (39 structs marked
#[non_exhaustive], sign_agent_card removed), client 5, server 6, sdk none.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…the day's mutation result

What ran on the final tree with which command and what it read, the semver-checks
lint counts per crate (and the correction of one figure in the previous
commit's message: types had two failing lints, not 13 checks), the mutation run
over the day's diff with its five misses and how each was closed, and what is
still unrun.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
`cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings`
(the CI Nightly job, informational, which has previewed stable lints before) failed
on two `.clone()` calls in test code, in the steering receipt test (`json!` takes
its argument by reference) and the JWT HS256 test (the value is dropped after).
Removed; the nightly run is then clean (exit 0) and both tests pass on stable. The
nightly docs.rs build (`--cfg docsrs`, all features) was clean already.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
… that could not run

On fd5c0b8: cargo hack, the SLIMRPC binding, the dogfood suite, nightly clippy and
the docs.rs build, and twelve of the thirteen rows of preflight_all.py's other-
workflow phase (DCO, the official TCK and the TCK self-test, all bindings, card
signature interop, gRPC wire compatibility, loom, ITK traversal, deny, semver).
The thirteenth, the promtool gate, failed twice at its Alertmanager download with
HTTP 504 through this machine's proxy (the Prometheus tarball before it matched its
SHA-256), so promtool and amtool never ran; the handoff says so and lists what
else is still unrun, rather than counting it.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
… a time

The seven `--workspace` variants of ci.yml's test job failed at a build
step on this machine (five logs say "No space left on device", two say
only "ld terminated with signal 7 [Bus error]"). Run one package at a
time with the test executables deleted between packages, 93 runs (all
26 members with default features and with none, the 22 unpublished
members with all features, and the signing, tracing, websocket and grpc
variants on the members that define them) exited 0. Say that this is not
CI's command, because --workspace unifies features in one build.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
Alertmanager's download got HTTP 504 twice earlier; the third attempt,
through the repository's own install_tools.sh, passed both SHA-256
checks, and every step of observability.yml's promtool job exits 0
(check config, check rules, test rules on 4 files, amtool check-config,
check_stack.py). Take the gate out of the not-run list and correct two
older notes that said no workflow ran promtool and that Docker was the
way to get it.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
@tomtom215 tomtom215 changed the title 0.16.0 release candidate: replicas, skills, steering and audit fixes (draft, CI not yet run) 0.16.0 release candidate: replicas, skills, steering and audit fixes (draft) Nov 8, 2026
Comment thread scripts/live/adopter/06-manifest-lint.py Dismissed

Copy link
Copy Markdown
Owner Author

Two checks are red on e0d2bee2. Neither is a test failure.

  1. Mutation Testing (incremental) fails by design until the PR carries the mutation-test label: mutants.yml fails instead of skipping, so an unmutated PR cannot pass. I have not added the label. It starts the incremental shards over this PR's whole diff (678 files), and every later push re-mutates it, so I would add it after the first pass of the other checks and any fix push. That is the maintainer's call.
  2. CodeQL reports 4 alerts in code this PR adds: 1 critical, 1 high, 2 medium. All are in recorded-run scripts that no workflow references (grep -rn over .github finds neither file): scripts/live/campaign/live/model_proxy.py line 47 (partial SSRF) and line 54 (HTTP response splitting, twice), and scripts/live/adopter/06-manifest-lint.py line 73 (clear-text logging). Read against the source: line 73 prints a Kubernetes Secret reference's name and key from a manifest, not a secret value. The proxy listens on 127.0.0.1 only and forwards to a fixed loopback upstream, and http.client raises InvalidURL on a control character in the request path (run with this Python). I read these as low real risk, but a scanner finding is not settled by my reading. The language:rust analysis has not finished, so the count may change.

I am holding any change until the first full pass of the other checks is in, then I will push one batched fix.


Generated by Claude Code

… a claim outlives its hand-over

CI's "every other PostgreSQL suite" step failed with "library module
store::claim::steering_tests ran no test". scripts/run_postgres_suites.py
lists every library file that spells the PostgreSQL test URL variable
as a module it must find a PostgreSQL test in; this file named the variable
only in its module doc, and the PostgreSQL case that uses it lives in
tests/steering_sql.rs. The doc now says where the cases run and keeps the
name out. The script's plan drops from 22 to 21 modules and
`python3 scripts/run_postgres_suites.py` exits 0 against a live
PostgreSQL 16 (14 test targets, 21 modules, 66 library tests passed).

The same file failed `a_message_never_reaches_a_later_claim` in one local
run of that step, with `left: NoClaim, right: Queued`: the case took a
claim with a 30 ms time to live and handed the message after it, so any
stall over 30 ms lapsed the claim first. With a 100 ms sleep injected
between the two calls, the old value fails in all three stores (in-memory,
SQLite, PostgreSQL) with that message and a 1 s time to live passes in all
three. The wait for the expiry is now the time to live plus 300 ms.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…d on both Windows CI jobs

Both Windows test jobs (stable and 1.94) ran 7621 tests and failed two,
the same two in each, both in skills/reader_tests:

* a_path_that_is_not_its_own_resolution_is_caught_after_the_open built its
  unresolved path with `root.join("references/deep/../guide.md")`. On
  Windows the scratch root is a `\\?\` path, and std's PathBuf::push onto
  one removes `.` and `..` (library/std/src/path.rs, "verbatim paths need
  . and .. removed"), so the test handed `verify` the resolved path and
  got Ok, as it should. The path is now built as text, which keeps the
  `..`, and the test asserts that it differs from the resolved one.
  `verify` is unchanged.
* another_failure_to_resolve_is_unreadable_not_not_found read a path below
  a regular file, which fails with ENOTDIR on Unix and is not "not found";
  on Windows it failed the Unreadable match. It is now Unix-only, and a new
  test, only_a_missing_file_is_not_found, checks `from_io` itself with
  synthesized errors on every platform.

Not run on Windows here: the Windows cross-clippy of the server crate
(all targets, all features, -D warnings) exits 0 and the tests pass on
Linux; the Windows result is the next CI run's.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
… CR or LF in a header

CodeQL reported the logging proxy under scripts/live/campaign/live (a
partial SSRF at the upstream request and an HTTP response split at the
relayed header). It listens on 127.0.0.1 only and connects only to the
upstream port it is given, so neither is reachable as described, but
the probe showed one real defect: a control character in the request
path raised InvalidURL inside http.client, the connection dropped with
no response, and the handler printed a traceback.

With a stub upstream that answers with a folded header and three
requests (a normal one, a path holding \x01, and an absolute-form path):
before, 200 / no response and a traceback / 200; after, 200 / 400 / 400
and no traceback, and the folded header is relayed as one line. A path
must now be printable ASCII starting with "/", a relayed header's name
must be a token, and CR or LF in its value becomes a space.

Whether CodeQL accepts these as sanitizers is not known until it runs
again. The remaining alert, clear-text logging at
scripts/live/adopter/06-manifest-lint.py:73, prints the name and key of a
Kubernetes Secret reference from a manifest, not a secret, and is left
for the maintainer to dismiss.

Signed-off-by: Tom F. <tomf@tomtomtech.net>

Copy link
Copy Markdown
Owner Author

The first CI pass on e0d2bee2 is complete: 83 checks, 71 passed, 5 failed, 7 skipped. Every check not listed below passed, including the Test jobs on Ubuntu and macOS for both toolchains. I pushed three commits (head 6eca972b); a new run is in progress. This replaces my earlier comment, which named only two of the five.

Failed check Cause (read from the logs, then reproduced or checked against the source) Change
Test (postgres integration) No test failed. scripts/run_postgres_suites.py lists every library file that spells the PostgreSQL URL variable as a module that must hold a PostgreSQL test; store/claim/steering_tests.rs named it only in its doc comment (the PostgreSQL case lives in tests/steering_sql.rs). Reproduced locally against PostgreSQL 16, same message. 908e6be0: the doc no longer names it; the script now exits 0 locally (14 test targets, 21 modules). The same commit lengthens a 30 ms claim time to live in the steering case that lapsed before its hand-over (shown by injecting a 100 ms gap: NoClaim where Queued was expected, in all three stores).
Test (stable, windows-latest) and Test (1.94, windows-latest) The same two tests in skills/reader_tests failed in both, out of 7621. One built an unresolved path with join onto a \\?\ root, where std's PathBuf::push removes .. (library/std/src/path.rs, "verbatim paths need . and .. removed"), so verify correctly saw the resolved path. The other expected the Unix ENOTDIR error kind. Product code unchanged. f2bd4103: the path is built as text; the other case is Unix-only, plus a platform-independent test of from_io. Windows cross-clippy is clean; the Windows result is this run's.
CodeQL 4 alerts, all in scripts no workflow runs: scripts/live/campaign/live/model_proxy.py (partial SSRF, response splitting ×2) and scripts/live/adopter/06-manifest-lint.py:73 (clear-text logging). 6eca972b hardens the proxy: a bad path is answered 400 (before, it dropped the connection with a traceback), header names must be tokens, CR and LF in a value become a space. The manifest-lint alert prints a Secret reference's name and key, not a secret; left for the maintainer to dismiss. Whether CodeQL accepts the proxy change shows on this run.
Mutation Testing (incremental) Fails by design until the PR carries the mutation-test label. None. Adding the label starts shards over the whole diff and is the maintainer's call.

Not explained: two reaper tests (handler::claims::reaper::tests) failed once in my first local run of the PostgreSQL step against a long-used database, and passed in all ten later local runs and in this CI. I changed nothing for them.


Generated by Claude Code

…rs recognise

The CodeQL run on 6eca972 still reported the proxy's three alerts
(response splitting twice, now at line 65, and the partial SSRF): its
python-all library 7.2.9, read from the CodeQL bundle, models a sanitizer
for HTTP header injection only as a call `x.replace("\n", ...)` with the
literal "\n" as its first argument, so the `re.sub` there was not one; and
for partial SSRF it accepts a `re.fullmatch(pattern, var)` guard only on a
local variable, not on the attribute read `self.path`.

The request path is now copied to a local `path` before the guard, and a
relayed header name and value go through `.replace("\r", ...)` and
`.replace("\n", ...)`. The behaviour is unchanged: the same probe as in
6eca972 gives 200 for a normal request, 400 for a path holding \x01 and
for an absolute-form path, a folded header relayed as one line, and no
traceback. The header-name token check is dropped: CR and LF are removed
from a name now, which is what the header write needed.

Not run: CodeQL itself. Running the downloaded CodeQL binary was refused
by this session's permission classifier, so whether the alerts clear is
the next CI run's to show. The alert at
scripts/live/adopter/06-manifest-lint.py:73 is unchanged: it flags the
read of a `secretKeyRef` key, and the line prints that reference's name
and key, not a secret.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…what is still open

The first pass on e0d2bee (83 checks, 71 passed, 5 failed) found the
PostgreSQL step's text scan, a 30 ms claim time to live, two Unix-path
assumptions in the skills reader tests and four CodeQL alerts in test
scripts, besides the mutation-label gate. The second pass on 6eca972
(74 passed, 2 failed) passes every test job on Ubuntu, macOS and Windows;
CodeQL and the label gate stay red. Record the causes and fixes, that the
hardening in 6eca972 did not clear CodeQL, what CodeQL's own query source
says it recognises, that running CodeQL was refused by the permission
classifier, the unexplained one-off reaper-test failure, and the
maintainer's calls on the pull request; correct the First, next list and
the Not verified list, which still said CI had not run.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…e is left

The run on a647094 (83 checks, 74 passed, 2 failed, 7 skipped) passes
every Test job on Ubuntu, macOS and Windows. CodeQL reports one alert, a
high, the clear-text-logging alert on 06-manifest-lint.py:73, and the
review threads for the five model_proxy.py alerts are resolved and
outdated. Replace the statements that said the rewrite in 1da23c0 was
unverified, record the third pass, and say where alert 205 can be
dismissed and that it did not show in the Security tab's list. The
mutation-test label is added after this commit, at the maintainer's
instruction, so the shards run on this head.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
@tomtom215 tomtom215 added the mutation-test label Nov 8, 2026 — with Claude
… on the server's Date second

Test (stable, windows-latest) failed on 432c0d0 (a docs-only difference
from a647094, where it passed) with
`a2a-protocol-client discovery::fleet_tests::a_zero_draw_leaves_the_lifetime_whole`:
`left: 2, right: 1` at the assertion after `elapse(9 * SECOND)`.

The counting server's `Date` has one-second resolution, so a response
arrives up to a second old (RFC 9111 section 4.2.3, the apparent age in
freshness.rs), and 9 s of a 10 s lifetime leaves exactly that second as
margin. Any delay between the server writing the Date and the client
reading the wall clock tips it. stale_window.rs already says this of its
own cases and records a fix for them; these two were missed.

Reproduced on Linux under 12 busy loops on 4 vCPUs: the unchanged test
failed 2 of 600 runs and the module's 38 tests failed it 1 of 150 times,
each with `left: 2, right: 1`; a quiet machine passed 400 of 400. With
a Date 1.5 s old injected, the 9 s elapse fails 5 of 5 runs with that same
message, so the margin is the cause. The test now sends a Date 5 s ahead of
now, which is an age of zero by the same section (and by `duration_since(..)
.ok()` in freshness.rs), keeping the 9 s elapse, so a draw that shortened
the lifetime by even a tenth would still be caught. Under the same load the
fixed test passed 2400 of 2400.

`jitter_shortens_freshness_by_the_drawn_share` has a margin of 0.5 s, not 1 s,
because the jitter halves the remaining lifetime: it passed 1500 of 1500 runs
under the same load, so it is unchanged.

The edit took fleet_tests.rs from 500 to 509 lines, over the 500-line
ratchet, so the Jitter section (the three jitter tests) moves into
fleet_tests/jitter.rs; the 38 tests of the module still pass.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
…the flaky Windows test

The run on 432c0d0 (132 check runs, 121 passed, 2 failed, 9 skipped)
passes CodeQL (alert 205 dismissed by the maintainer) and every Test job
except Windows stable, which failed one flaky test. The mutation run
(46 shards and the aggregator) succeeded: no survivors, failures or
timeouts in 150 minutes, with server shards of 53.7 to 92.4 minutes
against a job timeout of 180, so the handoff's statements that the shard
timeout is 75 minutes were wrong (a stale comment in mutants.yml says
75). Record the pass, the cause of the flaky test (the counting
server's one-second Date, a 1 s margin) with its evidence and the fix in
the previous commit, correct First, next, Not verified and the
maintainer's list, and add a Lessons entry on Date resolution in cache
tests.

Signed-off-by: Tom F. <tomf@tomtomtech.net>
@tomtom215
tomtom215 marked this pull request as ready for review November 9, 2026 04:27
@tomtom215
tomtom215 merged commit 9214541 into main Nov 9, 2026
128 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants