Skip to content

Add catnix gateway adaptors and Hydra coverage - #2

Closed
georgewhewell wants to merge 29 commits into
grw/feat/aiterfrom
grw/feat/more-catgrad
Closed

Add catnix gateway adaptors and Hydra coverage#2
georgewhewell wants to merge 29 commits into
grw/feat/aiterfrom
grw/feat/more-catgrad

Conversation

@georgewhewell

Copy link
Copy Markdown
Contributor

Summary

  • add reusable wire adaptor/backend pipeline for gateway request handling
  • switch gateway provenance to catnix commitments and wire Responses proxy support
  • add Hydra jobs plus NixOS gateway module/proxy coverage

Verification

  • cargo test --workspace
  • nix flake check --no-build
  • nix build .#nixosTests.x86_64-linux.gateway-proxy-responses --builders '' --print-build-logs
  • nix build .#hydraJobs.x86_64-linux.lints.{sort,fmt,clippy} --builders '' --print-build-logs
  • nix build .#hydraJobs.x86_64-linux.packages.cli .#hydraJobs.x86_64-linux.packages.cli-candle --builders '' --print-build-logs

Replace the prefix-snapshot caching architecture with a single
commitment-keyed exact-replay cache plus a content-addressed receipt
store.

Architecture shift:

- The previous design cached intermediate session state at every
  prefix length, with a `CausalStepper` trait abstracting the decode
  loop so a fake stepper could substitute for the catgrad session in
  property tests of "split stability" — the invariant that
  `prefill(P + S)` == `prefill(P) ; advance_one(s_i for s_i in S)`
  bit-for-bit. The whole approach is gone: it imposed a strict
  determinism contract on backend kernels that's hard to honor across
  hardware, and the cache hit ratio in practice didn't justify the
  complexity.

- New design: each `ExecutionContext` keeps two caches.
  * Continuation cache, keyed by `Cid<TextExecution>` — the request
    commitment over (program, parameter tensor CIDs, prompt tokens,
    policy). Same hash ⇒ byte-identical ask ⇒ stream stored tokens
    without touching the model.
  * Receipt store, keyed by `Cid<TextReceipt>` — names a particular
    `(commitment, final state, output tokens, position)` tuple.
    Populated at bind time with the genesis receipt (cold-start
    anchor) and at end of every real execution with that execution's
    final receipt. Anchored requests look up their incoming
    `initial_receipt_id` to find the live state to start from.

Code consequences:

- `runner.rs`: drop the generic-over-stepper `decode<S: CausalStepper>`
  function; the runner is now a concrete prefill-then-decode flow over
  `TextDecoder`. `build_text_execution` builds the request commitment
  for the quote path.
- `runner/tests.rs`: deleted (526 lines of split-stability proptests
  testing an invariant we no longer pursue).
- `programs/context.rs`: drop `CATCH_UP_THRESHOLD` and the suffix
  teacher-force path; `ExecutionStart` no longer carries a
  `transcript`. Genesis receipt computed at bind time and exposed via
  `genesis_receipt_id`.
- `executor/actor/{quote,execution}.rs`, `worker.rs`: track only the
  cached-output-tokens count for stats, not cached prompt tokens.

Docs:

- `docs/PREFIX.md` documents the prefix-cache decision and why we
  retired the split-stability approach.
- `docs/DISCOVERY_E2E.md` covers the discovery flow.

Cargo:

- Enable the local `[patch]` for `../catgrad/{catgrad,catgrad-llm}` to
  iterate against the catgrad runtime-primitives branch in tandem.
…--pi flag

Collapses the two-step Execute → ExecuteStream RPC pair into a single
streaming Execute, deleting the entire late-subscriber path:
LocalExecutionStream, SubscriptionSet, broadcast channel, close-monitor,
handle_subscribe/handle_status/SubscriptionsClosed, and the buffered
output + status fields on ExecutionRecord. The per-execution
mpsc::Receiver returned by Execute IS the subscription; dropping it
closes the worker's sender, which fires the runner's CancellationToken
between decode steps. End-to-end drop-cancellation with no token
plumbing on the CLI side.

CLI execution layer is now stream-shaped end to end (ExecutionRequest::
stream, Outcome { Completed { receipt_cid, stop_reason }, Failed }).
Gateway handlers rewritten with async_stream::stream! around prepared.
stream(); sse_response is a one-line wrapper (no spawn, no channel).
Verify shadow strategy compares 32-byte receipt CIDs instead of full
output bytes.

inputs::Error variants now carry the originating HuggingFaceLocator;
adding `impl From<inputs::Error> for ExecutorError` lets every callsite
use `?` instead of map_err helpers. Three duplicate map_weights_error /
map_program_cache_error helpers deleted.

Numerous smaller dedups: state/ collapsed to state.rs, runner helpers
inlined, dead ExecutorError/ModelAssetsError variants removed, dead
ModelAssets methods deleted, build_text_execution moved onto
ExecutionContext, quote-prompt/chat-prompt assets-load deduped.

Also adds --pi flag to gateway: spawns pi-coding-agent against the
just-bound listener, exits when pi exits. Requires --force-model.

Net: −552 lines across 44 files; executor crate ~30% smaller.
- rpc: add From<ModelAssetsError> for Status and From<TokenBytesError>
  for Status. Factor model_assets_status_code / executor_status_code
  helpers so the impls compose without duplicating the per-variant
  code-mapping table.
- rpc: add ExecutorError::TokenBytes(#[from] TokenBytesError) so
  decode_token_ids can use ? from executor code.
- executor handle.rs: decode_tokens collapses a 4-deep nested match
  (7x duplicated Status::internal(format!(...))) into a flat ? chain
  inside a small decode closure. The first-message path uses .next()..??
  to chain Option/Result unwrapping; gRPC-stream Err is propagated
  without wrapping (it's already a Status).
- executor state.rs: drop the InvalidTokenPayload(error.to_string())
  wrap; the new From impl propagates via ExecutorError::TokenBytes.
- cli text_output.rs / monitor.rs: swap two anyhow!("...: {err}")
  flatten sites for .context("..."); preserves the error chain.
…ers + SSE events

Threads catgrad execution provenance from the executor to HTTP gateway clients
across two boundaries.

**Executor → gateway** (tonic): mirrors the existing OTel W3C trace-context
propagation pattern. Server-side attaches commitment_id + program_id to
`Response::metadata_mut()` for both unary `get_quote` and the streaming
`execute` initial metadata; client-side `RemoteExecuteDriver` extracts them
before `into_inner()`. The local mpsc path (`ExecutorHandle`) carries the
same struct alongside its event receiver. Receipt continues to flow via the
existing `Completed.receipt_cid` proto field.

**Gateway → HTTP client**: a new `tower::Layer` (`ProvenanceLayer`) wraps the
axum router. Handlers insert `ExecutionProvenance` and (for buffered
responses) `Cid<TextReceipt>` into `response.extensions_mut()`; the layer
lifts both into `x-hellas-commitment-id` / `x-hellas-program-id` /
`x-hellas-receipt-id` headers on the way out. Cross-cutting; no
per-handler header-attachment boilerplate.

For SSE responses the receipt is unknown at header-flush time, so the three
SSE handlers (openai, anthropic, plain) also emit named in-band events:
`event: hellas-provenance` first, `event: hellas-receipt` immediately before
each protocol's terminal frame. This covers browser `EventSource` consumers
which can't read response headers or HTTP trailers.

New module `hellas_rpc::provenance` owns the wire-format primitives:
`ExecutionProvenance` struct, header/metadata key constants, hex
encoding/decoding, and `MetadataMap` round-trip helpers. Bytes-based so the
rpc crate doesn't pull catgrad into its `client` feature; callers
reconstitute typed `Cid<T>` at their boundary.

`ExecutionRequest` now exposes `prepare()` separately from `stream()` so the
gateway can read pre-flight provenance off the resulting `PreparedExecution`
before any stream events flow.

Tests: provenance round-trip (encode/decode, missing/malformed key
handling), `ProvenanceLayer::apply_provenance_headers` unit coverage, plus
an end-to-end Router test using `tower::ServiceExt::oneshot` that confirms
extensions become headers through the full axum stack.
…rogram

The commitment CID hashes (program, parameter CIDs, prompt tokens, policy),
so it transitively identifies the program. Exposing `x-hellas-program-id`
as a separate header was redundant.

Removed:
- `PROGRAM_HEADER` constant and the `program_id` field on
  `ExecutionProvenance`.
- The `x-hellas-program-id` header attachment in `ProvenanceLayer`.
- The `program_id` field in the `hellas-provenance` SSE event payload.
- Server-side population in the executor's quote/execute handlers.

The receipt header (`x-hellas-receipt-id`) and the commitment header
(`x-hellas-commitment-id`) remain.
The gateway was emitting `event: hellas-receipt` on the wire but never
`info!`-ing it. So under `--pi`, when a client request finishes, we had
no server-side trace of which receipt was produced — making it
impossible to tell from logs whether the executor's terminal Outcome
actually reached the gateway or was lost to drop-cancellation.

Added an `info!` at each `Outcome::Completed` observation site in the
three protocol handlers (buffered + SSE = 6 sites total), recording
receipt_cid, provenance, total_tokens, and stop_reason.

To make the log fields readable: replaced the derived
`Debug for ExecutionProvenance` (which printed
`ExecutionProvenance { commitment_id: [171, 171, ...] }`) with a manual
impl that delegates to Display, so `?provenance` and
`?Option<ExecutionProvenance>` render as `Some(deadbeef...)` / `None`.
…e_tool_calls

Replaces the post-hoc model.parse_tool_calls(text, arch) dispatch +
has_tools-buffered streaming with an event-driven loop driven by
catgrad-llm's new ChatTurn / IncrementalToolCallParser API.

  crates/rpc/src/model/assets.rs
                            drops parse_tool_calls and
                            prepare_chat_with_tools. Adds
                            chat_turn(wire_tools, options) -> ChatTurn
                            doing wire-shape -> typed ToolSpec
                            conversion + ToolDirectory build (compiles
                            JSON schemas) + protocol lookup. Empty
                            wire-tools list normalized to None at the
                            edge. Arc-wraps tokenizer / chat_template /
                            tokenizer_config / stop_token_ids so
                            chat_turn() clones cheaply per request.

  crates/rpc/src/model/mod.rs
                            adds two ModelAssetsError variants used
                            by the gateway to classify request errors:
                            InvalidToolDirectory (bad schemas) and
                            ToolsUnsupportedForModel (caller asked for
                            tools but the architecture has no
                            registered protocol). Both surface as HTTP
                            400, never 502.

  crates/cli/src/commands/gateway/state.rs
                            PreparedGeneration drops `has_tools` and
                            carries `chat_turn: Option<ChatTurn>`
                            instead. prepare_openai / prepare_anthropic
                            build the ChatTurn first, render via it,
                            then finalize generation. classify_chat_turn_error
                            maps tool-config errors to 400 request
                            errors.

  crates/cli/src/commands/gateway/openai.rs
                            ground-up rewrite. Both respond (non-
                            streaming) and stream_response use
                            chat_turn.make_parser() and walk
                            DecodeEvents. apply_event accumulates
                            content + tool_calls and returns Terminal
                            (HTTP 502) for UnknownTool / InvalidArgs /
                            ParseError. stream_apply_event maps each
                            event to OpenAI SSE chunks per the wire
                            convention (Start carries name+id;
                            ArgsDelta carries arguments fragment with
                            no id repeat; End emits no separate frame;
                            terminal events emit error frame and close
                            WITHOUT [DONE]). saw_tool_call drives the
                            final finish_reason: tool_calls wins over
                            stop whenever any call was emitted.

  crates/cli/src/commands/gateway/anthropic.rs
                            ground-up rewrite. Same pattern with
                            content_block_start / delta / stop
                            bracketing. Maintains its own block-index
                            counter (distinct from the parser's
                            tool-call index per the P6 contract).
                            Errors close with the anthropic `error`
                            event, no message_stop. Defensive
                            close_any_open_block before terminal
                            frames.

  Plain endpoint stays passthrough — no chat template, no tool
  contract; left untouched.

16 new unit tests covering apply_event / stream_apply_event /
events_to_blocks across both surfaces (97 total node tests pass).
End-to-end validated against Qwen3-0.6B on CUDA via curl: plain chat,
OpenAI/Anthropic non-streaming tool calls, OpenAI streaming tool
call, unknown-tool 502, invalid-args 502, bad-schema 400 — all match
the contract.
Pi has a rich TUI that mixes stdout and stderr; without redirection it
clobbers the parent terminal. Adds --pi-log <path>; both streams go to
the file when set, otherwise pi keeps inheriting the parent tty for
interactive use.
Replaces bracketing 'event: hellas-provenance' / 'event: hellas-receipt'
SSE events with a namespaced "hellas" field on the existing
protocol-native JSON envelopes. Browser EventSource and many WASM
HTTP wrappers swallow response headers, so an in-band JSON extension is
the only carrier that reliably reaches those clients.

Wire shape per surface:

- OpenAI streaming: first chunk (role:assistant) carries
  hellas.commitment_id; the SEMANTIC TERMINAL chunk -- the last data
  event before [DONE] -- carries hellas.receipt_id. Without
  include_usage that's the finish-reason chunk; with it, the trailing
  usage chunk. Receipt placement is the testable invariant: "receipt
  on terminal event," not "receipt on a finish-reason chunk that may
  have other chunks after it."
- Anthropic streaming: hellas.commitment_id lives inside
  message_start.message (on MessageResponse, same JSON path as
  non-streaming so clients have one extraction path).
  hellas.receipt_id rides message_stop, the structural terminator.
- Plain streaming: same shape as the chat surfaces.
- Non-streaming: the JSON body gets the same hellas extension on top
  of the existing x-hellas-* headers (additive; headers stay).

Receipt injection is fenced strictly inside Outcome::Completed.
Transport / timeout / parser / Outcome::Failed branches emit cleanup
frames unwrapped -- no receipt leaks on any failure path.

Adds gateway/hellas_ext.rs with HellasExt + WithHellas<T> via
serde(flatten); catgrad-llm wire types stay protocol-neutral.
provenance_layer.rs is unchanged (still header-only; mutating bodies
in middleware would force buffering streams).

Anthropic streaming gets a build_anthropic_sse_stream extraction
parallel to OpenAI's testable seam, yielding AnthropicSsePayload
{name, json}.

Tests:
- 5 unit tests for the WithHellas wrapper (skip-empty, hex
  rendering, flatten merge).
- OpenAI streaming_done_tests extended with positive-path coverage
  (commitment on first, receipt on terminal, no commitment when
  provenance is None, include_usage routes receipt to usage chunk)
  and error-path receipt-leak guards on transport / timeout /
  Outcome::Failed.
- Anthropic streaming_tests mirror the same coverage:
  message_start.message commitment, message_stop receipt,
  message_delta carries no receipt, errors emit no message_stop and
  no receipt.

cargo test --workspace: 96 tests pass.
…teway edge, run_decode in executor

Repins catgrad/catgrad-llm to hellas-ai/catgrad@grw/feat/megatooler
(62aa3b1), which lands the WireMapper / AssistantTurnAccumulator /
run_decode refactor + typed tool wire shapes. Atomic with the
downstream code changes because catgrad-llm's exports widened
non-additively (ChatTurn::new return type, types::{openai,anthropic}
Tool fields).

Downstream:

- crates/rpc/src/model/assets.rs: ChatTurn now takes
  Option<Arc<ToolDirectory>> and returns ChatTurnConfigError.
  Wire-shape conversion (Vec<Value> -> ToolDirectory) moved out;
  the gateway surfaces own it now via
  ToolDirectory::from_openai_tools / from_anthropic_tools. Sheds
  wire_tools_to_specs and the LLMError remapping shim.

- crates/rpc/src/model/mod.rs: ModelAssetsError collapses
  InvalidToolDirectory + ToolsUnsupportedForModel into one
  ChatTurnConfig variant carrying the typed catgrad-llm error.
  Wire-shape errors are caught at the gateway edge and never reach
  here.

- crates/cli/src/commands/gateway/state.rs: prepare_openai /
  prepare_anthropic call the typed conversion helpers and pass the
  resulting Option<Arc<ToolDirectory>> straight to assets.chat_turn.
  Deletes the bespoke anthropic_tool_to_openai shim + its test --
  Anthropic's input_schema vs OpenAI's parameters is folded in
  catgrad-llm now.

- crates/executor/src/runner.rs: bespoke peek-stop-or-commit decode
  loop replaced by catgrad_llm::runtime::run_decode. The new contract
  is cancel-AFTER-commit (the in-flight token always reaches the
  sink), one-extra-token in cancelled output vs the old loop.
  Documented inline.

- nix/package.nix: bumps catgrad sha256 to match the new rev.
Adds gateway-multi-model: two executors (qwen + lfm2), one gateway
running in discovery mode (no --node-id/--node-addr), two pi processes
in parallel. Verifies that mDNS routing finds the right executor for
each requested model and that distinct requests produce ≥2 distinct
receipt_cid + commitment values in the gateway journal.

Plumbing:

- baseNode firewall: enable filter; allow 5353/udp (mDNS) and disable
  reverse-path filtering (Linux drops multicast on bridged interfaces
  by default).
- mkExecutorNode: openFirewall = true on the iroh listen port.
- mkGatewayNodeDiscovery: same shape as mkGatewayNode minus the
  --node-id/--node-addr pinning, with iroh/pkarr/dns logs tightened
  so structured fields stay legible.
- gatewayLauncherDiscovery: stripped command line for the discovery
  case.
- hfHomeBoth: symlinkJoin of qwen + lfm2 caches so one gateway can
  resolve config/tokenizer for both models.

Tool-use tests: pi output now goes to /tmp/pi.log via --pi-log;
gateway stdout/stderr lands in /tmp/gateway.log. Both are dumped
separately into the build log on success or failure. Same change
applied to gateway-tool-use-{openai,anthropic}.
@hellasbot

hellasbot commented Jun 2, 2026

Copy link
Copy Markdown

Hydra reported failures for this PR.

Failed builds:

Update: hellas/node-pr-2, eval #969, head 8d8ff7aa850c: newer evaluation started.

@georgewhewell
georgewhewell deleted the grw/feat/more-catgrad branch August 30, 2026 23:53
georgewhewell added a commit that referenced this pull request Sep 11, 2026
Every tonic-generated server registered on the node transport is now
wrapped in `ManagedServer<S, …>` with `IrohPeerExtractor`. The wrapper
handles peer observation, admission, rate-limit denials, and stashing
the resulting `InboundAdmission` on the request extensions so handlers
that need it (just `GetKnownPeers` for now) can read disclosure_limit
without re-billing the rate bucket.

Removals:
- `ExecutePeerInterceptor` (used to hardcode `methods::RunTicket` for
  every Execute/Symbolic/Opaque call — review finding #2 + #3).
- In-method `peer_directory.observe_inbound_request` calls in
  `get_node_info` and `get_known_peers`; the wrapper does it once with
  the typed policy from `RpcServiceSpec::inbound_policy(path)`.
- `peer_observation(&request)` and `duration_ms` helpers — the
  extractor does both.
- `tonic::service::interceptor::InterceptedService` import.
- `tonic_iroh_transport::IrohContext` and `PathId` imports — code that
  needs the peer id falls through the typed extractor instead.

`courtesy_service` is now wrapped too, closing the inbound-admission
bypass flagged in the earlier review. Every inbound RPC on every
service goes through one typed admission code path.

The `service_alpn`-too-long check in `get_known_peers` stays in-handler
since it's an application-level invariant on the request body (not a
transport-level admission concern).
georgewhewell added a commit that referenced this pull request Sep 11, 2026
mux/transport.rs (generic ws driver): prepare_credit_updates now runs
BEFORE the outbound drain, matching ws/cf_do.rs. Previously credit
was prepared AFTER drain, so any Credit frame stayed queued in the
slot's send_queued until the NEXT inbound activity — defeating the
whole point of replenishment.

mux/state.rs Body inbound: drop the eager local_recv_credit refill.
Credit only refills via prepare_credit_updates' threshold check,
which is the only path that ALSO queues a Credit frame back to the
peer. The eager refill meant local_recv_credit never dropped below
threshold so the Credit frame was never queued; the peer's
peer_recv_credit would drain to 0 and stall.

Both fixes from codex review #2.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
Closes codex review #2 finding (snapshot poisoning).

VALIDATION added to deserialize_mux:
- Magic byte must match (already had this).
- free_mask word count must equal (N+63)/64 — mismatched N rejects
  with SnapshotCorrupt("free_mask word count mismatch").
- free_mask bits for the peer's parity rejected (\"sets a bit owned
  by peer parity\") — prevents poisoning our slot allocator into
  handing out peer-domain indices.
- count > N rejected.
- slot_idx >= N rejected.
- Duplicate slot_idx in the occupied list rejected.
- A slot listed as occupied AND also in the free_mask rejected (the
  same-slot-dispatched-twice attack).
- local_recv_credit > local_credit_high_water rejected.
- peer_recv_credit > config.initial_credit rejected (peer can't
  have advertised more credit than they could ever receive).
- has_pending=1 with len > 16 KiB rejected (attachment-budget
  enforcement).
- Trailing bytes after the snapshot rejected.

ADVERSARIAL TESTS (14 new + 2 existing + 1 proptest fuzz, total 17):
- drop_and_fresh_start_is_a_fresh_mux
- worker_dies_mid_callback_partial_attachment (every truncation
  prefix from 0..len rejects)
- bit_flipped_magic_byte_rejected
- future_magic_byte_rejected_with_named_error (version-upgrade case)
- inflated_occupancy_count_rejected (count=33 vs N=32)
- duplicate_slot_index_rejected
- slot_in_both_free_mask_and_occupied_list_rejected
- peer_parity_in_our_free_mask_rejected (poisoned allocator attack)
- credit_exceeds_high_water_rejected
- pending_write_oversized_rejected (1 MiB vs 16 KiB budget)
- trailing_bytes_appended_rejected
- n_word_count_mismatch_rejected (snapshot for N=32 into N=128 mux)
- generation_survives_round_trip
- random_bytes_never_panic_or_oom (proptest fuzz, 1000 cases, all
  re-serializable on success)

cargo test -p hellas-wire (mux,iroh,ws,ws-cf-do): 25 tests pass.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
`hellas rpc <id>` was broken: it dials Node::ALPN and calls
get_node_info, but `hellas serve node` only bound Execute/Symbolic/
Opaque/Courtesy ALPNs and dropped Node connections with a warn. The
"Node is pending" comment was the only mention.

This phase wires Node end-to-end with a real impl backed by the
existing peer state, so `hellas rpc` returns actual version, build,
graffiti, uptime, OS, and known-peers fields.

Changes:

- New `crates/cli/src/commands/serve/node_handler.rs` (~150 lines).
  `NodeHandlerImpl: Clone` (fields are `Arc<str>` / `Arc<[u8]>` /
  Copy) so we clone per-connection rather than wrap in `Arc<dyn>`.
  Implements `hellas_rpc::services::node::NodeHandler`:
  - `get_node_info` returns the exact proto fields (`node_id`,
    `uptime_seconds`, `version`, `build`, `os`, `graffiti`).
    Version = `env!("CARGO_PKG_VERSION")`; OS = `ARCH-OS`.
  - `get_known_peers` uses `PeerDirectory::ranked_known_peers`
    with a 64-peer disclosure limit. Requester defaults to
    `PeerId::default()` (anonymous) — the generated trait doesn't
    surface inbound peer identity to handlers. Phase F's
    `AdmittingDispatcher` will fix that without a trait change by
    enriching the request body, or via a thread-local. Until then,
    `min_disclosed_auth_level` on the directory config remains the
    gate.

- `serve/node.rs`:
  - Added `Node::ALPN` to the bound ALPN list.
  - Construct a `PeerDirectory` keyed on the local node id; pass an
    Arc to the handler (and, in Phase F, to AdmittingDispatcher).
  - Stopped discarding `build` and `graffiti` (previously `let _ =
    (preload_weights, build, graffiti);`); they now feed the
    handler. `preload_weights` stays parked.
  - `serve_connection` takes a `NodeHandlerImpl` and grew a Node
    ALPN arm: `let server = NodeServer(node_handler); serve_loop(...)`.

- `serve/mod.rs` registers the new `node_handler` submodule.

Tests:
- `commands::serve::node_handler::tests::get_node_info_populates_self_identity`
  — checks version, build, OS shape, graffiti round-trip, uptime ≤ 1s.
- `commands::serve::node_handler::tests::get_known_peers_returns_empty_on_fresh_directory`
  — fresh directory → empty list (verifies the disclosure path
  compiles and returns the proto shape).

Verification:
- `cargo test -p hellas-cli --features hellas-executor` → 58 passed
  (was 56, +2 new in the new module).
- `cargo test -p hellas-wire --all-features` → 57 passed.
- `cargo test -p hellas-rpc --all-features --lib` → 68 passed.
- `cargo build --workspace` clean.

Diffstat: 3 files, +184 / -7. Net: +177 (genuinely new functionality
— restoring a missing service). Tests: +51 of that. Net production
code: +126.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
… rule

Adversarial panel (Codex/gpt-5.3-codex-spark, Grok, nemotron-3-ultra)
reviewed 8b5cd03..HEAD. Confirmed findings fixed:

- StakeBondTerms commits challenge_margin (window + inclusion +
  finality, blocks); JobAcceptanceContext::covered_by is the admission
  rule (1 <= p_j <= max_job_price, terminal_deadline + margin <=
  bond timeout) and FraudArtifact::binds re-checks it — closes the
  stall-past-timeout escape's policy gap (Grok #3, Codex #1).
- Kernel open rejects party-controlled treasuries (TreasuryIsParty):
  treasury == provider would collapse the slash penalty from S to A
  (Grok #2).
- Kernel open rejects max_job_price == 0 (JobPriceCapZero): kills the
  A = C_disp strict-incentive degeneracy (Grok #7).
- CloseKindSet::decode refuses no-exit sets (defense in depth, Grok #12).
- Bond e2e uses STAKE_BOND_PROTOCOL, not the payment code (Grok #13).
- preverified-seals feature doc: process-local cache, single-node dev
  only, never multi-validator (Grok #1 — by design, now explicit).

Rejected with evidence: cross-bond artifact replay (binds checks
bond_edge == public.edge_id), seal-omits-treasury (acceptance digest
commits bond_terms which commits it), SealPublicInputs malleability
(terms hash-checked against edge pre-verify), VoucherBook stale race
(kernel payload hash binds the edge; older voucher pays provider less),
stake==net-value as fee bug (plan defines S net).
georgewhewell added a commit that referenced this pull request Sep 11, 2026
Every tonic-generated server registered on the node transport is now
wrapped in `ManagedServer<S, …>` with `IrohPeerExtractor`. The wrapper
handles peer observation, admission, rate-limit denials, and stashing
the resulting `InboundAdmission` on the request extensions so handlers
that need it (just `GetKnownPeers` for now) can read disclosure_limit
without re-billing the rate bucket.

Removals:
- `ExecutePeerInterceptor` (used to hardcode `methods::RunTicket` for
  every Execute/Symbolic/Opaque call — review finding #2 + #3).
- In-method `peer_directory.observe_inbound_request` calls in
  `get_node_info` and `get_known_peers`; the wrapper does it once with
  the typed policy from `RpcServiceSpec::inbound_policy(path)`.
- `peer_observation(&request)` and `duration_ms` helpers — the
  extractor does both.
- `tonic::service::interceptor::InterceptedService` import.
- `tonic_iroh_transport::IrohContext` and `PathId` imports — code that
  needs the peer id falls through the typed extractor instead.

`courtesy_service` is now wrapped too, closing the inbound-admission
bypass flagged in the earlier review. Every inbound RPC on every
service goes through one typed admission code path.

The `service_alpn`-too-long check in `get_known_peers` stays in-handler
since it's an application-level invariant on the request body (not a
transport-level admission concern).
georgewhewell added a commit that referenced this pull request Sep 11, 2026
mux/transport.rs (generic ws driver): prepare_credit_updates now runs
BEFORE the outbound drain, matching ws/cf_do.rs. Previously credit
was prepared AFTER drain, so any Credit frame stayed queued in the
slot's send_queued until the NEXT inbound activity — defeating the
whole point of replenishment.

mux/state.rs Body inbound: drop the eager local_recv_credit refill.
Credit only refills via prepare_credit_updates' threshold check,
which is the only path that ALSO queues a Credit frame back to the
peer. The eager refill meant local_recv_credit never dropped below
threshold so the Credit frame was never queued; the peer's
peer_recv_credit would drain to 0 and stall.

Both fixes from codex review #2.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
Closes codex review #2 finding (snapshot poisoning).

VALIDATION added to deserialize_mux:
- Magic byte must match (already had this).
- free_mask word count must equal (N+63)/64 — mismatched N rejects
  with SnapshotCorrupt("free_mask word count mismatch").
- free_mask bits for the peer's parity rejected (\"sets a bit owned
  by peer parity\") — prevents poisoning our slot allocator into
  handing out peer-domain indices.
- count > N rejected.
- slot_idx >= N rejected.
- Duplicate slot_idx in the occupied list rejected.
- A slot listed as occupied AND also in the free_mask rejected (the
  same-slot-dispatched-twice attack).
- local_recv_credit > local_credit_high_water rejected.
- peer_recv_credit > config.initial_credit rejected (peer can't
  have advertised more credit than they could ever receive).
- has_pending=1 with len > 16 KiB rejected (attachment-budget
  enforcement).
- Trailing bytes after the snapshot rejected.

ADVERSARIAL TESTS (14 new + 2 existing + 1 proptest fuzz, total 17):
- drop_and_fresh_start_is_a_fresh_mux
- worker_dies_mid_callback_partial_attachment (every truncation
  prefix from 0..len rejects)
- bit_flipped_magic_byte_rejected
- future_magic_byte_rejected_with_named_error (version-upgrade case)
- inflated_occupancy_count_rejected (count=33 vs N=32)
- duplicate_slot_index_rejected
- slot_in_both_free_mask_and_occupied_list_rejected
- peer_parity_in_our_free_mask_rejected (poisoned allocator attack)
- credit_exceeds_high_water_rejected
- pending_write_oversized_rejected (1 MiB vs 16 KiB budget)
- trailing_bytes_appended_rejected
- n_word_count_mismatch_rejected (snapshot for N=32 into N=128 mux)
- generation_survives_round_trip
- random_bytes_never_panic_or_oom (proptest fuzz, 1000 cases, all
  re-serializable on success)

cargo test -p hellas-wire (mux,iroh,ws,ws-cf-do): 25 tests pass.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
`hellas rpc <id>` was broken: it dials Node::ALPN and calls
get_node_info, but `hellas serve node` only bound Execute/Symbolic/
Opaque/Courtesy ALPNs and dropped Node connections with a warn. The
"Node is pending" comment was the only mention.

This phase wires Node end-to-end with a real impl backed by the
existing peer state, so `hellas rpc` returns actual version, build,
graffiti, uptime, OS, and known-peers fields.

Changes:

- New `crates/cli/src/commands/serve/node_handler.rs` (~150 lines).
  `NodeHandlerImpl: Clone` (fields are `Arc<str>` / `Arc<[u8]>` /
  Copy) so we clone per-connection rather than wrap in `Arc<dyn>`.
  Implements `hellas_rpc::services::node::NodeHandler`:
  - `get_node_info` returns the exact proto fields (`node_id`,
    `uptime_seconds`, `version`, `build`, `os`, `graffiti`).
    Version = `env!("CARGO_PKG_VERSION")`; OS = `ARCH-OS`.
  - `get_known_peers` uses `PeerDirectory::ranked_known_peers`
    with a 64-peer disclosure limit. Requester defaults to
    `PeerId::default()` (anonymous) — the generated trait doesn't
    surface inbound peer identity to handlers. Phase F's
    `AdmittingDispatcher` will fix that without a trait change by
    enriching the request body, or via a thread-local. Until then,
    `min_disclosed_auth_level` on the directory config remains the
    gate.

- `serve/node.rs`:
  - Added `Node::ALPN` to the bound ALPN list.
  - Construct a `PeerDirectory` keyed on the local node id; pass an
    Arc to the handler (and, in Phase F, to AdmittingDispatcher).
  - Stopped discarding `build` and `graffiti` (previously `let _ =
    (preload_weights, build, graffiti);`); they now feed the
    handler. `preload_weights` stays parked.
  - `serve_connection` takes a `NodeHandlerImpl` and grew a Node
    ALPN arm: `let server = NodeServer(node_handler); serve_loop(...)`.

- `serve/mod.rs` registers the new `node_handler` submodule.

Tests:
- `commands::serve::node_handler::tests::get_node_info_populates_self_identity`
  — checks version, build, OS shape, graffiti round-trip, uptime ≤ 1s.
- `commands::serve::node_handler::tests::get_known_peers_returns_empty_on_fresh_directory`
  — fresh directory → empty list (verifies the disclosure path
  compiles and returns the proto shape).

Verification:
- `cargo test -p hellas-cli --features hellas-executor` → 58 passed
  (was 56, +2 new in the new module).
- `cargo test -p hellas-wire --all-features` → 57 passed.
- `cargo test -p hellas-rpc --all-features --lib` → 68 passed.
- `cargo build --workspace` clean.

Diffstat: 3 files, +184 / -7. Net: +177 (genuinely new functionality
— restoring a missing service). Tests: +51 of that. Net production
code: +126.
georgewhewell added a commit that referenced this pull request Sep 11, 2026
… rule

Adversarial panel (Codex/gpt-5.3-codex-spark, Grok, nemotron-3-ultra)
reviewed 8b5cd03..HEAD. Confirmed findings fixed:

- StakeBondTerms commits challenge_margin (window + inclusion +
  finality, blocks); JobAcceptanceContext::covered_by is the admission
  rule (1 <= p_j <= max_job_price, terminal_deadline + margin <=
  bond timeout) and FraudArtifact::binds re-checks it — closes the
  stall-past-timeout escape's policy gap (Grok #3, Codex #1).
- Kernel open rejects party-controlled treasuries (TreasuryIsParty):
  treasury == provider would collapse the slash penalty from S to A
  (Grok #2).
- Kernel open rejects max_job_price == 0 (JobPriceCapZero): kills the
  A = C_disp strict-incentive degeneracy (Grok #7).
- CloseKindSet::decode refuses no-exit sets (defense in depth, Grok #12).
- Bond e2e uses STAKE_BOND_PROTOCOL, not the payment code (Grok #13).
- preverified-seals feature doc: process-local cache, single-node dev
  only, never multi-validator (Grok #1 — by design, now explicit).

Rejected with evidence: cross-bond artifact replay (binds checks
bond_edge == public.edge_id), seal-omits-treasury (acceptance digest
commits bond_terms which commits it), SealPublicInputs malleability
(terms hash-checked against edge pre-verify), VoucherBook stale race
(kernel payload hash binds the edge; older voucher pays provider less),
stake==net-value as fee bug (plan defines S net).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants