Proxy dev releases phase 3: the contract handshake¶
Master plan: PLAN-proxy-dev-releases.md. This phase embeds a
sha256 of kerbside/rpc/kerbside.proto in both the Python package
(a committed constant written by tools/gen-protos.sh) and the
Rust binary (a build.rs compile-time env), and makes the daemon
compare them before launching the proxy — refusing with a
remediation-listing message on mismatch. This is the backstop that
makes sparse dev-wheel publishing safe: any skew the phase 1 path
filter lets through (or the merge-to-bootstrap window creates)
becomes a loud startup refusal instead of a subtle protocol
failure.
Planning effort: high (per the master plan — the change spans the gRPC contract convention, build.rs, clap, and the supervisor launch path across two languages).
Scope¶
In scope:
tools/gen-protos.sh: additionally writekerbside/rpc/contract.pycarrying the proto's sha256.kerbside/rpc/contract.py(new, generated, committed) and a unit test pinning it to the actual proto bytes.rust/kerbside-proxy/:sha2build-dependency,build.rshash embed, a--contract-hashprint-and-exit flag, a unit test.kerbside/proxy_supervisor.py: pre-launch handshake, refusal message,KERBSIDE_SKIP_CONTRACT_CHECKescape hatch, unit tests inkerbside/tests/unit/test_proxy_supervisor.py.- End-to-end verification against both the freshly built binary (hashes match) and the released 0.4.0 wheel binary (predates the flag — refusal path), run in the management session.
Out of scope: documentation beyond code comments (phase 4 covers AGENTS.md / ARCHITECTURE.md / docs, and must document the two env vars); the publish workflow and specifier (phases 1-2, done); pruning (phase 5).
What the survey found¶
Verified against the tree on 2026-08-14. The master plan's phase 3 sketch contains no false claims; the only correction it needed was resolving its "refuses/warns" hedge to "refuses", which open question 1's operator decision already settled. Specifics:
tools/gen-protos.shregenerates the checked-in stubs intokerbside/rpc/and is invoked astox -egenprotos(tox.ini:50-58 pins the toolchain for deterministic output). Extending it keeps a single regeneration entrypoint.kerbside/rpc/contains__init__.py, the proto, the four generated stub files,server.pyandservicer.py; a new generatedcontract.pyfits the existing convention of committed generated artifacts.- The kerbside wheel does not ship the
.proto([tool.setuptools] packagesonly, pyproject.toml:132-133), so hashing must happen at generation/build time, not runtime — confirming the master plan's committed-constant approach. rust/kerbside-proxy/build.rsalready declarescargo:rerun-if-changed=<proto>, so a compile-time hash embed re-derives whenever the proto changes with no extra plumbing.[build-dependencies]currently carriestonic-prost-buildandprotoc-bin-vendored(Cargo.toml:90-95);sha2joins them.main.rsuses derive-style clap (struct Args, main.rs:74+) with long flags only — a--contract-hashbool flag matches the house style.Args::parse()happens at main.rs:133 before logging init, so the flag can print-and-exit cleanly.kerbside/proxy_supervisor.py::launch_rust_proxy()(line ~97) isfind_proxy_bin()→build_proxy_argv()→Popen; the handshake slots between the first and last of those.kerbside/tests/unit/test_proxy_supervisor.pyalready exists for the new tests.- Phase 2's completion was spot-checked (committed floor present and parseable; generalised stamp branch present; tree clean) and its Execution-table status flipped to implemented in this planning commit. origin/develop has not moved since the phase 2 rebase, though PR #307 entered the merge queue during planning — a final rebase before the eventual PR remains on the plan- completion checklist.
Decisions¶
- The contract identity is the sha256 of the proto file's raw
bytes, embedded at generation time (Python, committed
constant) and compile time (Rust,
env!). No canonicalisation or comment-stripping: a comment-only proto edit changes the hash and forces a republish, which is a false positive we accept — proto edits are rare, and canonicalisation code on two sides is its own skew risk. This is the decision a reviewer is most likely to argue with. sha2is a build-dependency only on the Rust side — compile-time hashing costs nothing at runtime and avoids entangling the runtime dependency tree (ring/rustls) in hashing duty.- The committed constant is written by
gen-protos.sh, not by hand, and a unit test (kerbside/tests/unit/) recomputes the hash from the proto and compares — so a proto edit without regeneration failstox -epy3in CI. No new CI job needed. - Refusal semantics (implements the operator's open-question
1 decision): on mismatch,
launch_rust_proxy()raises RuntimeError naming BOTH hashes, the binary path, and the remediation options (upgrade the kerbside-proxy wheel; rebuildrust/kerbside-proxy; pointKERBSIDE_PROXY_BINat a matching binary; setKERBSIDE_SKIP_CONTRACT_CHECK=1to bypass for debugging). A binary that does not understand--contract-hash(every release ≤0.4.0) is treated as a mismatch with tailored wording ("binary predates the contract handshake"). The escape hatch logs a WARNING and launches anyway. Sub-decision most worth flagging: hard-refusing old binaries is deliberate — current-Python-plus-0.4.0-binary is exactly the broken pairing this plan exists to catch, and the escape hatch covers the operator who knows better. - The subprocess probe is bounded:
--contract-hashruns with a 10-second timeout and any failure mode (timeout, non-zero exit, garbage output) is a refusal, never a silent pass. - All cargo operations run in the rust Docker image (the operator keeps Rust toolchains off the host); briefs give the exact pattern, mirroring phase 1's step 1d.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 3a | medium | sonnet | none | Extend tools/gen-protos.sh to write kerbside/rpc/contract.py after stub generation: a header comment saying the file is generated by tools/gen-protos.sh (run tox -egenprotos) and must not be hand-edited, then CONTRACT_HASH = '<64-hex sha256 of kerbside.proto>' computed with sha256sum kerbside.proto \| cut -d' ' -f1 (the script already cd's into kerbside/rpc). Run tox -egenprotos; if ANY already-committed generated stub changes beyond the new contract.py, STOP and report the diff rather than committing regeneration noise. Add kerbside/tests/unit/test_contract.py: locate the proto relative to the test file, hashlib.sha256 its bytes, assert equality with kerbside.rpc.contract.CONTRACT_HASH, and assert the constant is 64 lowercase hex chars. Follow repo Python style (single quotes, 120-char lines). tox -epy3 and pre-commit run --files <touched> must pass. |
| 3b | high | opus | none | Rust side, all cargo commands via docker run --rm -v /srv/kasm_profiles/mikal/vscode/src/shakenfist:/srv/kasm_profiles/mikal/vscode/src/shakenfist -w <worktree>/rust/kerbside-proxy rust:latest cargo ... (host has no Rust toolchain by policy; the mount path matters because the worktree's gitdir and build.rs's ../../kerbside/rpc both resolve under it). (1) Add sha2 = "0.10" to [build-dependencies] with a comment. (2) In build.rs, after compile_protos, read the proto bytes, compute sha256, and emit cargo:rustc-env=KERBSIDE_CONTRACT_HASH=<hex>. (3) In main.rs, add to struct Args a /// Print the embedded gRPC contract hash and exit. #[arg(long)] contract_hash: bool, and immediately after Args::parse() (before logging init): if set, println!("{}", env!("KERBSIDE_CONTRACT_HASH")); return;. (4) Add a unit test asserting env!("KERBSIDE_CONTRACT_HASH") is 64 chars of lowercase hex. (5) cargo fmt, cargo clippy --all-targets (warnings clean, matching rust.yml's bar), cargo test, and cargo run --release -- --contract-hash must print a 64-hex line; report it and leave target/ alone (gitignored). Set CARGO_TARGET_DIR to a path under /tmp inside the container if root-owned files in the worktree would result — check git status is clean of new files afterwards. |
| 3c | high | opus | none | In kerbside/proxy_supervisor.py: add SKIP_CONTRACT_CHECK_ENV = 'KERBSIDE_SKIP_CONTRACT_CHECK'; add get_binary_contract_hash(bin_path) running [bin_path, '--contract-hash'] via subprocess.run with capture_output=True, text=True, timeout=10, returning the stripped stdout on rc==0, or None on any failure (non-zero rc, timeout, empty/malformed output — 64 lowercase hex or bust); add check_contract(bin_path) comparing against kerbside.rpc.contract.CONTRACT_HASH and raising RuntimeError per plan decision 4 (message must name both hashes — or "unknown (binary does not support --contract-hash; it predates the contract handshake)" — the binary path, and all four remediations including the escape hatch; grep the module's existing find_proxy_bin error for message style). Wire into launch_rust_proxy() between find_proxy_bin() and build_proxy_argv(): if the escape-hatch env var is set to a truthy value, LOG.warning and skip; else check_contract(). Extend kerbside/tests/unit/test_proxy_supervisor.py following its existing mock style: hash-match launches; mismatch raises with both hashes in the message; flag-unsupported (rc!=0) raises with the predates wording; timeout raises; escape hatch launches despite mismatch and logs a warning. tox -epy3 and pre-commit run --files <touched> must pass. |
| 3d | — | — | — | Verification, management session: (a) using the step 3b container-built binary (rebuild if the 3a-generated constant changed the proto tree state — it must NOT have), run python3 -c importing proxy_supervisor.get_binary_contract_hash against it and assert equality with contract.CONTRACT_HASH; (b) unzip the 0.4.0 wheel already in the scratchpad (from step 2c) and run check_contract() against its kerbside-proxy binary — expect the RuntimeError with the "predates the contract handshake" wording; (c) with KERBSIDE_SKIP_CONTRACT_CHECK=1, the same call must not raise. Record all three transcripts for the PR description. |
Risks and mitigations¶
tox -egenprotosregenerates stubs with unrelated diffs (toolchain drift since the stubs were last committed). Step 3a is instructed to stop and report rather than commit noise; the management session decides whether a stub-refresh commit is warranted separately.- Hash divergence from line endings or encoding between
sha256sum(3a),sha2(3b) andhashlib(3c/test): all three hash the same raw file bytes with no text-mode processing, and step 3d(a) proves the Rust and Python values agree on the real artifacts before the phase is called done. - The probe adds a subprocess exec to daemon startup: bounded by decision 5's timeout; a hung binary fails the launch loudly (which is correct — it would have hung as the proxy too).
- Root-owned droppings from the Docker cargo runs: the 3b
brief requires a clean
git statusafter; the reviewer (management session) re-checks. - A deployer with a legitimately old-but-working pairing is hard-refused after upgrading the Python side only (decision 4 sub-decision): accepted deliberately; the refusal message's remediation list and escape hatch are the mitigation, and phase 4 documents both env vars.
Definition of done¶
kerbside/rpc/contract.pyexists, is marked generated, andtest_contract.py(which recomputes the hash from the proto bytes) passes — so the constant cannot silently go stale.tox -egenprotosis idempotent on the committed tree (running it changes nothing).- The container-built binary's
--contract-hashoutput equalsCONTRACT_HASH(step 3d(a) transcript recorded). - The 0.4.0 release binary is refused with the predates-the-handshake message, and launches with the escape hatch set (3d(b)/(c) transcripts recorded).
cargo fmt --check,cargo clippy --all-targetsandcargo testpass in the container; the new Rust test pins the embedded hash's shape.- All new supervisor behaviours are unit-tested (match, mismatch,
unsupported flag, timeout, escape hatch) and
tox -epy3passes. pre-commit run --all-filespasses;git statusis clean of Docker droppings.- The master plan's Execution table and
index.mdreflect phase 2 as implemented and phase 3 as planned (done in the planning commit).
Back brief¶
Before executing any step of this plan, back brief the operator on
the plan and how the intended work aligns with it. Gate: if step
3a's tox -egenprotos run surfaces unrelated stub regeneration
diffs, pause for the operator before committing anything from that
step.