From 685f449ddba8b85cee9e4b258c0b7b3a6736894b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:04:44 +0100 Subject: [PATCH 01/13] connector-contract-v3: delete the v2 golden test and fixtures Ruling connector-v3-only: no v2 peer is kept. The test's header already named this cut as its deletion line. Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/wire_v2/.gitattributes | 2 - .../tests/fixtures/wire_v2/README.md | 21 -- .../fixtures/wire_v2/canonical_invocation.bin | Bin 413 -> 0 bytes .../wire_v2/canonical_provider_request.bin | Bin 267 -> 0 bytes .../fixtures/wire_v2/envelope_invocation.mp | 6 - .../wire_v2/envelope_result_failed.mp | 7 - ...envelope_result_refused_replay_conflict.mp | Bin 252 -> 0 bytes .../wire_v2/envelope_result_transported.mp | Bin 609 -> 0 bytes .../tests/fixtures/wire_v2/generator.rs | 171 ---------- .../tests/fixtures/wire_v2/invocation_auto.mp | Bin 408 -> 0 bytes .../fixtures/wire_v2/invocation_required.mp | Bin 413 -> 0 bytes .../wire_v2/provider_request_sha256.bin | Bin 32 -> 0 bytes .../tests/fixtures/wire_v2/result_failed.mp | Bin 307 -> 0 bytes .../wire_v2/result_refused_capacity.mp | 1 - .../wire_v2/result_refused_expired.mp | 1 - .../result_refused_identity_substitution.mp | 1 - .../wire_v2/result_refused_in_flight.mp | 1 - .../wire_v2/result_refused_indeterminate.mp | 1 - .../wire_v2/result_refused_malformed.mp | 1 - .../fixtures/wire_v2/result_refused_policy.mp | 1 - ...lt_refused_provider_digest_substitution.mp | 1 - .../wire_v2/result_refused_replay_conflict.mp | 1 - .../fixtures/wire_v2/result_transported.mp | Bin 327 -> 0 bytes .../model-connector/tests/wire_golden_v2.rs | 301 ------------------ 24 files changed, 517 deletions(-) delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/.gitattributes delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/README.md delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/generator.rs delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_failed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_transported.mp delete mode 100644 crates/model-connector/tests/wire_golden_v2.rs diff --git a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes deleted file mode 100644 index bf62110..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.mp binary -*.bin binary diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md deleted file mode 100644 index 476b362..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Golden v2 wire fixtures - -Bytes of the v2 connector wire, produced once by CodexConnector at commit -`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector -`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in -both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from -them. No CodexConnector code or dependency is part of this repository. - -Generator: `generator.rs` (not compiled here). Command, run in a clone of -CodexConnector at that commit: - - cp /crates/model-connector/tests/fixtures/wire_v2/generator.rs examples/gen_wire_v2.rs - cargo run --locked -q --example gen_wire_v2 - -It prints `FIXTURE ` lines; each file is the hex decoded. It was -run on Yggdrasil through the verify container with codex-connector at that -commit as a dev-dependency of the kit at d1d28f0 (same source). The `.mp` -files are rmp-serde output; `envelope_*` files carry random nonces and -AES-256-GCM ciphertext sealed under the connection key -`parity-distinct-test-key`. Regenerating changes the envelopes' bytes; the -plaintext fixtures must regenerate byte-identically. diff --git a/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin b/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin deleted file mode 100644 index 727edf6cf9555f9c02825cc276855e2a1ffeb884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CP!4L|aDs1QihXX}II>8El<4Id;K zSMYR|mBcT^`T80O8mUM;wcRkl575y3C6FYvA6FrmXbak>9;KbM-M~3}BduM$s;E4D z_mFxNT;&y((^>nr1NsQ@vd06mbobA>$?{E{`Qo_n-H+wO6-cBW8zSgrze#*e8BI#C bOXhCxpoq=7-8aTV|7^A=y_J$G2T%V2OD}Km diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp deleted file mode 100644 index 3f95738..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp +++ /dev/null @@ -1,6 +0,0 @@ -–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1ªInvocationœÌ£VÌÁḩ̃̌xÌ©Y̽Ü­hÌ·ÌýÌâÌ—Ì…3ÌÕ ÌÇÌþÌ‘ÌÜḩ̨Ìé[̪ÌÙ!AÌë̉ÌðÌúÌÛ;ÌëÌú| ̪ÌÉ`!ÌúÌú̧̚Ì� -Ì�kÌÞ'vCZÌà̖̌̈ 8J̧ÌÀḍ̗NÌ¿FÌ¿zm6Ìÿ -Ì÷ÌíÌð\\NMqÌ–Ì×Ì— Ìù1ÌæÌ¦Ì¯Ìè 4*{ÌË̥̱ -ÌÖ̦eÌÇ̨Ì� -̰̆ḭG*SÌ�̳̼ÌÒ̥̮ÌÜ!NÌÀÌÑ̠̫̙̂7Ì�ÌÇ̰Ìì̃ÌÀ̬c̦ÌË ÌÒ̲1kÌá~hÌß3$ÌÚÌ KÌß̰ÌùÌÜ4{ÌÓÌß̼̩U.)ÌÌ̭̤rÌ ÌýÌìÌÆ^bbÏy^̳ÌßÌÌÌ�ÌÉ:Ì Ìš-ÌåYXoÌÓÌÒ[Qy̘̫ -XÌÓI:3̽ÌÔ̰D&V'-ÌòufÌŒX1ÌøÌÒ.Ì�|4cÌãÌ‹EÌåAÌßÌæÌŸÌÍQÌÝLÌ‚-Ìáw̰uÌ´3̹>Ìï$QzÌÀk m'+ÌÐÌ¢G Ì´sÌ®00 f)Ì·C̯̙*̆Ì’ÌÛy~ÌùHzÌøÌåÌý"ÌÇÌ¢*%ÌôoÌÉ̯ÌÙxÌŽ}Ì‹ÌÏÌÝYÌÈCÌîÌ´Ì¿uÌü̸x/gÌëÌñ̘X-ÌÙÌÂ̮̆NÌìRI):HÌñÌÉÌ’ÌÀ̗̉fÌÛÌ '\s̨ Ì�s̉ÌÄGVaÌÓ̶̲C\\`=p ÌžwÌŽHÌÖ4ÌÆÌØ>lfLÌ£ÌþÌäÌÖvÌ¿ÌÞJÌë \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp deleted file mode 100644 index 4b94559..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp +++ /dev/null @@ -1,7 +0,0 @@ -–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì�GÌ©ÜCÌôÌË Ì�ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘ -Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ -̱̘ÌË -[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì�hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì�kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô -ÌÁÌò̸%Ì»Ì�@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x -r]F8Ì�ÌÛÌ‹Ì÷Ì�5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ�zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ�ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f -Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp deleted file mode 100644 index 10e3726559fa374cd41b9b35c34f97bac50dd09f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 252 zcmVow+yG@3%-hVD z%)-o`e9W^F%(2YrHq4O`%&g4Y%-PK1%>53`kY&ujNzCoc-OSp|w9K_c%%Ty@ifzpJ zJj}?<;6FXg+01~<_{@#Wrp)EciF;?v?9B4a_H4|q%)!jT%Ql; zi5fzshECDCNW}`R(A1wvVW6QFW|7*^Bqk9?Zye6md7krpxMw0vhEmB`Zg$2Pt!Jdl z7^mG}aoDVOXQ5=Nkjz$_6jKq~Wi!}|omEPs!EBc7?RKfGTyi*-syn4ED39bBK5pXM&8R@Qxs6ZBQ#IeUjNv`M)xrji^lP%uo>cK@ zpG#LH@t3dkFp#9qSJTFNW*NN&eq43gXiHVn%D9*x+}8vNls7if$wYD>wQ{Btz)US` z$LVD*I5aOxXCia)mWb&tvm&pzzeF0o!EU`K=h0mjI1=2l>fRR&Ep?86Mn4F7akFlQ~vTr?D8%#2&3uBfQo!!-ARxZ31hwSed*K zQOXzQbPNfxk(|OqxYNUU!^$>>h504HWSxP0R`Dpb^z%o-6{c7Y=Q&S!C$@_b>O`zM z8Vg0CgS=uY!GcdOy6oX{%_%nYagKC0&har!oHxY`pWq>%=se6yG4;n}ypE$aN`xM- m;cgo3J5|v_`|+*}=K1Yk!F9bL`tp+sO65GD`6!Dt9Qg;?kz>{X diff --git a/crates/model-connector/tests/fixtures/wire_v2/generator.rs b/crates/model-connector/tests/fixtures/wire_v2/generator.rs deleted file mode 100644 index baf36cc..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/generator.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this -//! workspace: it was run once, as an example of CodexConnector at commit -//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command). -//! It prints one `FIXTURE ` line per file. - -use codex_connector as v2; -use serde::Serialize; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -fn emit(name: &str, data: &[u8]) { - let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); - println!("FIXTURE {name} {hex}"); -} - -fn request(choice: v2::CodexToolChoice) -> v2::CodexProviderRequest { - let mut request = - v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - v2::CodexInputItem::UserText { - text: "state".to_string(), - }, - v2::CodexInputItem::AssistantText { - text: "reply".to_string(), - }, - v2::CodexInputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - v2::CodexInputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![v2::CodexToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = choice; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn invocation(choice: v2::CodexToolChoice) -> v2::CodexTransportInvocation { - v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() -} - -fn receipt( - invocation: &v2::CodexTransportInvocation, - outcome: v2::CodexTransportOutcome, -) -> v2::CodexTransportReceipt { - v2::CodexTransportReceipt { - schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome, - } -} - -fn main() { - let key = v2::CodexTransportKey::from_connection_secret(KEY).unwrap(); - let auto = invocation(v2::CodexToolChoice::Auto); - let required = invocation(v2::CodexToolChoice::Required); - emit("invocation_auto.mp", &bytes(&auto)); - emit("invocation_required.mp", &bytes(&required)); - emit( - "canonical_invocation.bin", - &v2::canonical_invocation_bytes(&required).unwrap(), - ); - emit( - "canonical_provider_request.bin", - &v2::canonical_provider_request_bytes(&required.request).unwrap(), - ); - emit( - "provider_request_sha256.bin", - &v2::provider_request_sha256(&required.request).unwrap(), - ); - emit( - "envelope_invocation.mp", - &bytes(&v2::encrypt_invocation(&required, &key).unwrap()), - ); - - let completed = v2::CodexTransportResult::transported( - &required, - vec![ - v2::CodexTransportEvent { - sequence: 0, - payload: v2::CodexTransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - v2::CodexTransportEvent { - sequence: 1, - payload: v2::CodexTransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt( - &required, - v2::CodexTransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: Some(4), - }, - ), - ); - emit("result_transported.mp", &bytes(&completed)); - emit( - "envelope_result_transported.mp", - &bytes(&v2::encrypt_result(&completed, &key).unwrap()), - ); - - let failed = v2::CodexTransportResult::transported( - &required, - Vec::new(), - receipt( - &required, - v2::CodexTransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - ), - ); - emit("result_failed.mp", &bytes(&failed)); - emit( - "envelope_result_failed.mp", - &bytes(&v2::encrypt_result(&failed, &key).unwrap()), - ); - - use v2::CodexRefusal as R; - let refusals = [ - ("expired", R::Expired), - ("identity_substitution", R::IdentitySubstitution), - ("provider_digest_substitution", R::ProviderDigestSubstitution), - ("policy", R::Policy), - ("capacity", R::Capacity), - ("in_flight", R::InFlight), - ("indeterminate", R::Indeterminate), - ("replay_conflict", R::ReplayConflict), - ("malformed", R::Malformed), - ]; - for (name, refusal) in refusals { - let refused = v2::CodexTransportResult::refused(&required, refusal); - emit(&format!("result_refused_{name}.mp"), &bytes(&refused)); - if name == "replay_conflict" { - emit( - "envelope_result_refused_replay_conflict.mp", - &bytes(&v2::encrypt_result(&refused, &key).unwrap()), - ); - } - } -} diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp deleted file mode 100644 index e505ca2bba33dc48ec8cfe6a607ba0f19a5c34eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 408 zcmah_Jxjw-6b&w2Ttsj+WY9qph$w=a3XTq?4o>y?a%rP!UVQH+w3H6{1431t9HdnH zAr&0OrRv~v3msevot<3t7ns@(;^LVOmvb)XoQuIijun819$H*DP_$4oUj;&9*7YO7 z8M?x^qUBD&q>Ck!GtUFrOZA7Ep85xgga-SZzLuy>C)D4jXCiu{*F}1#H-j$M_ULm? zqpMk)hV(H_kMlc2ZDwHni4;f?xemyzgyRqtCUII5YT9>P_z_6;6V$XDxd6>oYo)ab zIusd4>pX7mDv%i{V)a@fLx?*YO1TO3aeS2!c%`_R5yIO_`kmd3TAKE)1mx`?7(h0E~4*92^2i Tqf%!fikEJ0N^5Y&^I+QF(pIMz diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp deleted file mode 100644 index 727edf6cf9555f9c02825cc276855e2a1ffeb884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CwXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlt~k2+*HP6R&r`)0Y1a-FetDS06-;YE}XfWdgkYueI{of zFq~QECU#~@{+U@)YG=-zS#f6NnRfj%&1W7JElA5BBSyHT!R9TX$ Kkd$AVmjVEMQG6Ky diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp deleted file mode 100644 index 4425e6a..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨Capacity \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp deleted file mode 100644 index 47fff91..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused§Expired \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp deleted file mode 100644 index 8da919c..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused´IdentitySubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp deleted file mode 100644 index 0c9f9d3..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨InFlight \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp deleted file mode 100644 index 068bed3..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused­Indeterminate \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp deleted file mode 100644 index f106b9f..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused©Malformed \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp deleted file mode 100644 index 28712db..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¦Policy \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp deleted file mode 100644 index 76b2169..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§RefusedºProviderDigestSubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp deleted file mode 100644 index 9f621de..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused®ReplayConflict \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp b/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp deleted file mode 100644 index f4fedf862d0f0cd231090f0848610542f5a65cfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 327 zcmbPoQz<<$H#NC5r$jF~KP9z7ucRn3uecz;s3g89wHPE^X0);>wXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlu45&F*L3WNv$YxNzExqoVYY2H76&35@X|vko^1{XP}13 z%Rt(6jaC<>CZ@y}=a&{Gr!K0lopDncj}wwpGYjy!;SPfWI{^Sxa^}LByQyb>p4n$| z<^jW*b#7v3rsSWQC8c)e%$XHuR-S3sKhu2XQBiKvnPu8%PM!I4W=+WQ^nwyyQ$3Sa kP#+f;r#7y1&d)8#Ni9iDnYs!XI0gB6#i_c6T+ECt0JtTD5dZ)H diff --git a/crates/model-connector/tests/wire_golden_v2.rs b/crates/model-connector/tests/wire_golden_v2.rs deleted file mode 100644 index 198b731..0000000 --- a/crates/model-connector/tests/wire_golden_v2.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins. -//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README); -//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3, -//! which changes the wire. - -use model_connector as kit; -use serde::Serialize; -use serde::de::DeserializeOwned; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -macro_rules! golden { - ($name:literal) => { - include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8] - }; -} - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -/// The value serializes to the golden bytes and the golden bytes decode back to it. -fn assert_wire( - name: &str, - value: &T, - golden: &[u8], -) { - assert_eq!(bytes(value), golden, "{name}: serialized bytes"); - let decoded: T = rmp_serde::from_slice(golden).unwrap(); - assert_eq!(&decoded, value, "{name}: decoded value"); -} - -fn request(choice: kit::ToolChoice) -> kit::ProviderRequest { - let mut request = - kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - kit::InputItem::UserText { - text: "state".to_string(), - }, - kit::InputItem::AssistantText { - text: "reply".to_string(), - }, - kit::InputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - kit::InputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![kit::ToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = choice; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn invocation(choice: kit::ToolChoice) -> kit::TransportInvocation { - kit::TransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() -} - -fn receipt( - invocation: &kit::TransportInvocation, - outcome: kit::TransportOutcome, -) -> kit::TransportReceipt { - kit::TransportReceipt { - schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome, - } -} - -fn completed(invocation: &kit::TransportInvocation) -> kit::TransportResult { - kit::TransportResult::transported( - invocation, - vec![ - kit::TransportEvent { - sequence: 0, - payload: kit::TransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - kit::TransportEvent { - sequence: 1, - payload: kit::TransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt( - invocation, - kit::TransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: Some(4), - }, - ), - ) -} - -fn failed(invocation: &kit::TransportInvocation) -> kit::TransportResult { - kit::TransportResult::transported( - invocation, - Vec::new(), - receipt( - invocation, - kit::TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - ), - ) -} - -fn key() -> kit::TransportKey { - kit::TransportKey::from_connection_secret(KEY).unwrap() -} - -fn refusals() -> [(&'static str, kit::Refusal, &'static [u8]); 9] { - use kit::Refusal as R; - [ - ("expired", R::Expired, golden!("result_refused_expired.mp")), - ( - "identity_substitution", - R::IdentitySubstitution, - golden!("result_refused_identity_substitution.mp"), - ), - ( - "provider_digest_substitution", - R::ProviderDigestSubstitution, - golden!("result_refused_provider_digest_substitution.mp"), - ), - ("policy", R::Policy, golden!("result_refused_policy.mp")), - ( - "capacity", - R::Capacity, - golden!("result_refused_capacity.mp"), - ), - ( - "in_flight", - R::InFlight, - golden!("result_refused_in_flight.mp"), - ), - ( - "indeterminate", - R::Indeterminate, - golden!("result_refused_indeterminate.mp"), - ), - ( - "replay_conflict", - R::ReplayConflict, - golden!("result_refused_replay_conflict.mp"), - ), - ( - "malformed", - R::Malformed, - golden!("result_refused_malformed.mp"), - ), - ] -} - -#[test] -fn schema_ids_are_the_v2_ids() { - assert_eq!( - kit::PROVIDER_REQUEST_SCHEMA_ID, - "gamecult.codex.provider_request.v2" - ); - assert_eq!( - kit::INVOCATION_SCHEMA_ID, - "gamecult.codex.transport_invocation.v2" - ); - assert_eq!(kit::RESULT_SCHEMA_ID, "gamecult.codex.transport_result.v2"); - assert_eq!( - kit::RECEIPT_SCHEMA_ID, - "gamecult.codex.transport_receipt.v2" - ); - assert_eq!( - kit::ENVELOPE_SCHEMA_ID, - "gamecult.codex.transport_envelope.v2" - ); -} - -#[test] -fn invocations_carry_the_v2_bytes_for_each_tool_choice() { - assert_wire( - "auto", - &invocation(kit::ToolChoice::Auto), - golden!("invocation_auto.mp"), - ); - assert_wire( - "required", - &invocation(kit::ToolChoice::Required), - golden!("invocation_required.mp"), - ); -} - -#[test] -fn results_carry_the_v2_bytes_for_success_and_failure() { - let invocation = invocation(kit::ToolChoice::Required); - assert_wire( - "transported", - &completed(&invocation), - golden!("result_transported.mp"), - ); - assert_wire("failed", &failed(&invocation), golden!("result_failed.mp")); -} - -#[test] -fn every_refusal_carries_its_v2_bytes() { - let invocation = invocation(kit::ToolChoice::Required); - for (name, refusal, golden) in refusals() { - assert_wire( - name, - &kit::TransportResult::refused(&invocation, refusal), - golden, - ); - } -} - -#[test] -fn canonical_bytes_and_provider_digest_are_the_v2_bytes() { - let invocation = invocation(kit::ToolChoice::Required); - assert_eq!( - kit::canonical_invocation_bytes(&invocation).unwrap(), - golden!("canonical_invocation.bin") - ); - assert_eq!( - kit::canonical_provider_request_bytes(&invocation.request).unwrap(), - golden!("canonical_provider_request.bin") - ); - assert_eq!( - kit::provider_request_sha256(&invocation.request) - .unwrap() - .as_slice(), - golden!("provider_request_sha256.bin") - ); -} - -#[test] -fn v2_sealed_envelopes_decrypt_and_validate_under_the_kit() { - let invocation = invocation(kit::ToolChoice::Required); - let envelope: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); - let received = kit::decrypt_invocation(&envelope, &key()).unwrap(); - assert_eq!(bytes(&received), bytes(&invocation)); - - let results = [ - ( - golden!("envelope_result_transported.mp"), - completed(&invocation), - ), - (golden!("envelope_result_failed.mp"), failed(&invocation)), - ( - golden!("envelope_result_refused_replay_conflict.mp"), - kit::TransportResult::refused(&invocation, kit::Refusal::ReplayConflict), - ), - ]; - for (sealed, expected) in results { - let envelope: kit::TransportEnvelope = rmp_serde::from_slice(sealed).unwrap(); - let received = kit::decrypt_result(&envelope, &key(), &invocation).unwrap(); - assert_eq!(bytes(&received), bytes(&expected)); - } -} - -#[test] -fn kit_sealed_envelopes_have_the_v2_shape() { - let invocation = invocation(kit::ToolChoice::Required); - let golden: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); - let sealed = kit::encrypt_invocation(&invocation, &key()).unwrap(); - assert_eq!(sealed.schema_id, golden.schema_id); - assert_eq!(sealed.caller_runtime_id, golden.caller_runtime_id); - assert_eq!(sealed.request_id, golden.request_id); - assert_eq!(sealed.message_kind, golden.message_kind); - assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); - assert_ne!(sealed.nonce, golden.nonce, "nonces are fresh per seal"); - - let golden: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_result_transported.mp")).unwrap(); - let sealed = kit::encrypt_result(&completed(&invocation), &key()).unwrap(); - assert_eq!(sealed.message_kind, golden.message_kind); - assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); -} From 5a4d8bd477da4b0b6beae24735965bb926b38134 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:11:53 +0100 Subject: [PATCH 02/13] connector-contract-v3: delete the Responses fields and Refusal::Policy; type the request, outcome and refusals ProviderRequest loses reasoning_summary, service_tier, output_format_name, previous_response_id and prompt_cache_key; reasoning_effort is a ReasoningEffort, max_output_tokens is required and cache_retention is added. Completed carries a StopReason and Usage, Failed a FailureKind, and the receipt names its provider. Refusal::Policy becomes ModelNotAllowed, OutputBound and PayloadBound, each its own branch in begin. cultcache-rs moves to the one CultLib pin a4eae90. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 430 ++++++++++++++++++++++++- crates/model-connector/Cargo.toml | 6 +- crates/model-connector/src/client.rs | 4 +- crates/model-connector/src/contract.rs | 129 ++++---- crates/model-connector/src/fixtures.rs | 15 +- crates/model-connector/src/serve.rs | 12 +- crates/model-connector/src/service.rs | 51 ++- 7 files changed, 543 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01f3693..b4e9546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,33 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -113,6 +134,12 @@ dependencies = [ "inout", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -135,7 +162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -150,8 +177,8 @@ dependencies = [ [[package]] name = "cultcache-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +version = "0.3.1" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" dependencies = [ "anyhow", "chrono", @@ -162,20 +189,84 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "serde_stacker", + "stacker", "uuid", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "cultcache-rs-derive" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "cultnet-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "hmac", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -184,8 +275,45 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -247,6 +375,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -255,7 +395,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -268,6 +408,15 @@ dependencies = [ "polyval", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -341,10 +490,14 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", + "base64", "cultcache-rs", - "rand_core", + "cultnet-rs", + "rand_core 0.6.4", "rmp-serde", + "schemars", "serde", + "serde_bytes", "serde_json", "sha2", "thiserror", @@ -360,6 +513,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -378,6 +540,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polyval" version = "0.6.2" @@ -390,6 +562,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -399,6 +580,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -408,12 +599,38 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -423,6 +640,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redb" version = "4.2.0" @@ -432,6 +658,26 @@ dependencies = [ "libc", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "rmp" version = "0.8.15" @@ -451,12 +697,63 @@ dependencies = [ "serde", ] +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -497,6 +794,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -510,6 +818,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + [[package]] name = "sha2" version = "0.10.9" @@ -527,12 +846,54 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "subtle" version = "2.6.1" @@ -626,6 +987,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -752,6 +1122,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -761,6 +1140,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -825,6 +1213,32 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index 49f83db..f004be5 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -13,10 +13,14 @@ daemon = ["dep:cultcache-rs"] [dependencies] aes-gcm = "0.10" -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true } +base64 = "0.22" +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs", optional = true } rand_core = { version = "0.6", features = ["getrandom"] } rmp-serde = "1" +schemars = "1" serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" serde_json = "1" sha2 = "0.10" thiserror = "2" diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index a1cbcb3..ab1e3de 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -154,7 +154,7 @@ mod fixture_tests { let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); let response = encrypt_result( - &TransportResult::refused(&expected, Refusal::Policy), + &TransportResult::refused(&expected, Refusal::ModelNotAllowed), &security, ) .unwrap(); @@ -175,7 +175,7 @@ mod fixture_tests { .unwrap(); assert_eq!( client.execute(&invocation).unwrap().disposition, - TransportDisposition::Refused(Refusal::Policy) + TransportDisposition::Refused(Refusal::ModelNotAllowed) ); server.join().unwrap(); } diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 7ae5be5..5cfffa4 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -39,17 +39,13 @@ pub struct ProviderRequest { pub model: String, pub instructions: String, pub input: Vec, - pub reasoning_effort: Option, - pub reasoning_summary: Option, - pub service_tier: Option, - pub output_format_name: Option, - pub previous_response_id: Option, + pub reasoning_effort: Option, + pub cache_retention: Option, pub tools: Vec, pub tool_choice: ToolChoice, pub parallel_tool_calls: bool, pub output_schema_json: Option, - pub max_output_tokens: Option, - pub prompt_cache_key: Option, + pub max_output_tokens: u32, } impl ProviderRequest { @@ -58,6 +54,7 @@ impl ProviderRequest { conversation_id: impl Into, model: impl Into, instructions: impl Into, + max_output_tokens: u32, ) -> Self { Self { schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(), @@ -67,16 +64,12 @@ impl ProviderRequest { instructions: instructions.into(), input: Vec::new(), reasoning_effort: None, - reasoning_summary: None, - service_tier: None, - output_format_name: None, - previous_response_id: None, + cache_retention: None, tools: Vec::new(), tool_choice: ToolChoice::Auto, parallel_tool_calls: false, output_schema_json: None, - max_output_tokens: None, - prompt_cache_key: None, + max_output_tokens, } } @@ -88,38 +81,12 @@ impl ProviderRequest { require_id(&self.conversation_id, "conversation_id")?; require_id(&self.model, "model")?; require_content(&self.instructions, "instructions")?; - if matches!(self.max_output_tokens, Some(0)) { + if self.max_output_tokens == 0 { return Err(ContractError::Invalid("max_output_tokens")); } - if self - .prompt_cache_key - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - return Err(ContractError::Invalid("prompt_cache_key")); - } - for (value, field) in [ - (self.reasoning_effort.as_deref(), "reasoning_effort"), - (self.reasoning_summary.as_deref(), "reasoning_summary"), - (self.service_tier.as_deref(), "service_tier"), - (self.output_format_name.as_deref(), "output_format_name"), - (self.previous_response_id.as_deref(), "previous_response_id"), - ] { - if let Some(value) = value { - require_id(value, field)?; - } - } if let Some(schema) = &self.output_schema_json { require_content(schema, "output_schema_json")?; require_json_object(schema, "output_schema_json")?; - require_provider_name( - self.output_format_name - .as_deref() - .ok_or(ContractError::Invalid("output_format_name"))?, - "output_format_name", - )?; - } else if self.output_format_name.is_some() { - return Err(ContractError::Invalid("output_schema_json")); } if self.tools.is_empty() && self.tool_choice == ToolChoice::Required { @@ -167,6 +134,60 @@ pub enum ToolChoice { Required, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningEffort { + Low, + Medium, + High, + Max, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CacheRetention { + Short, + Long, +} + +/// Why a provider stopped producing output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + EndTurn, + ToolUse, + MaxOutputTokens, + StopSequence, + Refusal, + ContextWindow, +} + +/// What kind of failure a provider reported. `ResultSize` is the connector's own +/// stand-in for a result whose sealed frame cannot be carried. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureKind { + ResultSize, + InvalidRequest, + Authentication, + RateLimited, + Unavailable, + Timeout, + Provider, +} + +/// Token accounting for one completed request. Cache reads and writes are +/// counted apart from plain input; `reasoning_tokens` is absent when the +/// provider does not report it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + pub input_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum InputItem { UserText { @@ -355,7 +376,7 @@ pub struct TransportReceipt { pub native_request_sha256: [u8; 32], pub provider_request_sha256: [u8; 32], pub model: String, - pub transport: String, + pub provider: String, pub outcome: TransportOutcome, } @@ -376,17 +397,13 @@ impl TransportReceipt { if self.native_request_sha256 != invocation.native_request_sha256 { return Err(ContractError::NativeDigest); } - require_id(&self.transport, "receipt.transport")?; + require_id(&self.provider, "receipt.provider")?; match &self.outcome { TransportOutcome::Completed { provider_response_id: Some(value), .. } => require_id(value, "receipt.provider_response_id")?, - TransportOutcome::Failed { - failure_kind, - message, - } => { - require_id(failure_kind, "receipt.failure_kind")?; + TransportOutcome::Failed { message, .. } => { require_content(message, "receipt.failure_message")?; } TransportOutcome::Completed { @@ -402,13 +419,11 @@ impl TransportReceipt { pub enum TransportOutcome { Completed { provider_response_id: Option, - input_tokens: Option, - output_tokens: Option, - reasoning_output_tokens: Option, - cached_input_tokens: Option, + stop_reason: StopReason, + usage: Usage, }, Failed { - failure_kind: String, + kind: FailureKind, message: String, }, } @@ -418,7 +433,9 @@ pub enum Refusal { Expired, IdentitySubstitution, ProviderDigestSubstitution, - Policy, + ModelNotAllowed, + OutputBound, + PayloadBound, Capacity, InFlight, Indeterminate, @@ -537,14 +554,14 @@ mod tests { #[test] fn provider_contract_refuses_transport_side_fixup_pressure() { let mut request = request(); - request.output_format_name = Some("epiphany.role.result".to_string()); request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + assert_eq!(request.validate(), Ok(())); + request.output_schema_json = Some("[]".to_string()); assert_eq!( request.validate(), - Err(ContractError::Invalid("output_format_name")) + Err(ContractError::Invalid("output_schema_json")) ); - request.output_format_name = None; request.output_schema_json = None; request.input.push(InputItem::ToolResult { call_id: "x".repeat(65), diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index 9b48c1d..e94ee52 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -43,6 +43,7 @@ pub(crate) fn request() -> ProviderRequest { "conversation-1", "gpt-5.4", "Return the typed result.", + 4_096, ); request.input.push(InputItem::UserText { text: "Projected state".to_string(), @@ -67,13 +68,17 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { native_request_sha256: invocation.native_request_sha256, provider_request_sha256: invocation.provider_request_sha256, model: invocation.request.model.clone(), - transport: "fixture-sse".to_string(), + provider: "fixture-sse".to_string(), outcome: TransportOutcome::Completed { provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, + stop_reason: StopReason::EndTurn, + usage: Usage { + input_tokens: 10, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 3, + reasoning_tokens: Some(1), + }, }, } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 8eda706..7ca0a3a 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -328,8 +328,8 @@ mod tests { use super::*; use crate::client::ConnectorClient; use crate::contract::{ - Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome, - TransportResult, + FailureKind, Refusal, TransportDisposition, TransportEvent, TransportEventPayload, + TransportOutcome, TransportResult, }; use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; @@ -906,7 +906,7 @@ mod tests { let reply = client.execute(&invocation).unwrap(); assert_eq!( reply.disposition, - TransportDisposition::Refused(Refusal::Policy) + TransportDisposition::Refused(Refusal::PayloadBound) ); assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } @@ -949,7 +949,7 @@ mod tests { transport: &str, ) -> TransportResult { let mut receipt = receipt(invocation); - receipt.transport = transport.to_string(); + receipt.provider = transport.to_string(); TransportResult::transported( invocation, vec![TransportEvent { @@ -984,9 +984,9 @@ mod tests { &result.disposition, TransportDisposition::Transported { events, receipt } if events.is_empty() - && receipt.transport == "model-connector" + && receipt.provider == "model-connector" && receipt.outcome == TransportOutcome::Failed { - failure_kind: "result_size".to_string(), + kind: FailureKind::ResultSize, message: "provider result exceeded its byte bound".to_string(), } ) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index e377c19..ace2e7f 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -7,7 +7,7 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, + ContractError, FailureKind, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, canonical_invocation_bytes, require_id, }; @@ -228,18 +228,16 @@ impl TransportService { }; } if envelope.ciphertext.len() > caller.max_payload_bytes { - return self.reply_refusal(&invocation, Refusal::Policy, &security); + return self.reply_refusal(&invocation, Refusal::PayloadBound, &security); } if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { return self.reply_refusal(&invocation, contract_refusal(&error), &security); } - if !caller.allowed_models.contains(&invocation.request.model) - || invocation - .request - .max_output_tokens - .is_some_and(|limit| limit > caller.max_output_tokens) - { - return self.reply_refusal(&invocation, Refusal::Policy, &security); + if !caller.allowed_models.contains(&invocation.request.model) { + return self.reply_refusal(&invocation, Refusal::ModelNotAllowed, &security); + } + if invocation.request.max_output_tokens > caller.max_output_tokens { + return self.reply_refusal(&invocation, Refusal::OutputBound, &security); } let identity = RequestIdentity { @@ -428,8 +426,8 @@ fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result TransportResult { let mut receipt = receipt(invocation); - receipt.transport = transport.to_string(); + receipt.provider = transport.to_string(); TransportResult::transported( invocation, vec![TransportEvent { From e669d2b8677765474748c28947721f0e748af9dd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:13:28 +0100 Subject: [PATCH 03/13] connector-contract-v3: handle the Result of add_generic_backing_store (CultLib a4eae90) The previous commit does not build with the daemon feature: the pinned CultCache returns a Result there. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index ace2e7f..ad9512f 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -159,9 +159,12 @@ impl TransportService { replay_store .register_entry_type::() .map_err(replay_store_error)?; - replay_store.add_generic_backing_store( - OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, - ); + replay_store + .add_generic_backing_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path) + .map_err(replay_store_error)?, + ) + .map_err(replay_store_error)?; replay_store .pull_all_backing_stores() .map_err(replay_store_error)?; From 33f092ea7f53ab99d6970ea65bf50e3b78a48e7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:16:50 +0100 Subject: [PATCH 04/13] connector-contract-v3: the stand-in test reads the receipt's provider The typed-outcome commit left one test reading the retired transport field, so its tests did not build. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index ad9512f..e74a2b8 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -1111,7 +1111,7 @@ mod tests { let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { panic!("expected a transported failure"); }; - assert_eq!(receipt.transport, "model-connector"); + assert_eq!(receipt.provider, "model-connector"); // The recorded replay is the stand-in's exact bytes. let TransportAdmission::Reply(replay) = service From 5894632829b6745426ac17b9f05dc44fb12d834b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:18:02 +0100 Subject: [PATCH 05/13] connector-contract-v3: the replay-seed test handles the Result of add_generic_backing_store Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index e74a2b8..44c3fe1 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -871,7 +871,9 @@ mod tests { edit(&mut record); let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); - cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()); + cache + .add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()) + .unwrap(); cache.pull_all_backing_stores().unwrap(); cache.put(key.to_string(), &record).unwrap(); } From c23d91391d536e04f0c84a13e0546fb1c7406e66 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:21:11 +0100 Subject: [PATCH 06/13] connector-contract-v3: the v3 wire - named MessagePack documents over a CultNet operation carriage Ids become gamecult.model.*.v3. Every document is encoded by one named MessagePack codec in contract.rs with unknown fields refused, and publishes a JSON Schema generated from its type (schemas/). The envelope's ciphertext is binary. Each frame is a cultnet.operation_request.v0 / operation_response.v0 for service gamecult.model.connector, operation invoke, its message_id the request id, its payload the base64 of the sealed envelope; the carriage is checked before any decryption. frame_bytes_for_payload and payload_bytes_for_frame measure the carriage. Golden v3 fixtures and their generator (tests/wire_golden_v3.rs) pin drift. The test debt of the kit lift (each identity field, each contract_refusal arm, request ids measured in bytes) is paid in the rewritten code. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + Cargo.lock | 1 + README.md | 29 +- crates/model-connector/Cargo.toml | 3 + crates/model-connector/src/client.rs | 43 +- crates/model-connector/src/contract.rs | 262 ++++++++- crates/model-connector/src/envelope.rs | 527 +++++++++++++++--- crates/model-connector/src/serve.rs | 109 +++- crates/model-connector/src/service.rs | 46 +- .../tests/fixtures/wire_v3/README.md | 15 + .../wire_v3/carriage_invocation.frame | 1 + .../fixtures/wire_v3/provider_request.mp | Bin 0 -> 549 bytes .../fixtures/wire_v3/transport_envelope.mp | Bin 0 -> 205 bytes .../fixtures/wire_v3/transport_invocation.mp | Bin 0 -> 799 bytes .../wire_v3/transport_receipt_failed.mp | Bin 0 -> 358 bytes .../wire_v3/transport_result_refused.mp | 1 + .../wire_v3/transport_result_transported.mp | Bin 0 -> 715 bytes .../tests/published_schemas.rs | 66 +++ .../model-connector/tests/wire_golden_v3.rs | 214 +++++++ schemas/provider_request.json | 229 ++++++++ schemas/transport_envelope.json | 57 ++ schemas/transport_invocation.json | 279 ++++++++++ schemas/transport_receipt.json | 186 +++++++ schemas/transport_result.json | 342 ++++++++++++ 24 files changed, 2275 insertions(+), 137 deletions(-) create mode 100644 .gitattributes create mode 100644 crates/model-connector/tests/fixtures/wire_v3/README.md create mode 100644 crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame create mode 100644 crates/model-connector/tests/fixtures/wire_v3/provider_request.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_envelope.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp create mode 100644 crates/model-connector/tests/published_schemas.rs create mode 100644 crates/model-connector/tests/wire_golden_v3.rs create mode 100644 schemas/provider_request.json create mode 100644 schemas/transport_envelope.json create mode 100644 schemas/transport_invocation.json create mode 100644 schemas/transport_receipt.json create mode 100644 schemas/transport_result.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0a1d199 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +schemas/*.json text eol=lf +crates/model-connector/tests/fixtures/wire_v3/* -text diff --git a/Cargo.lock b/Cargo.lock index b4e9546..3c3ff8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,6 +495,7 @@ dependencies = [ "cultnet-rs", "rand_core 0.6.4", "rmp-serde", + "rmpv", "schemars", "serde", "serde_bytes", diff --git a/README.md b/README.md index 433f540..cbcccb5 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,16 @@ Status: the `model-connector` crate is live. No connector binary exists yet Crate `crates/model-connector`: -- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`, - `TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest - functions that bind a result to its request. -- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and - the length-prefixed framing. +- `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, the typed `StopReason`, `FailureKind` and `Refusal`, + and the canonical byte and digest functions that bind a result to its request. Every + document is named MessagePack with unknown fields refused. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope` (binary ciphertext), its + identity-binding AAD, the length-prefixed framing, and the CultNet carriage: each frame is a + `cultnet.operation_request.v0` / `operation_response.v0` for service + `gamecult.model.connector`, operation `invoke`, its `message_id` the request id and its + payload the base64 of the sealed envelope. +- `schemas/` - one JSON Schema per v3 document, generated from the types. - `src/client.rs` - `ConnectorClient`, the one-request caller side. - `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable replay (a retried request is answered byte-identically and the provider runs once), @@ -24,16 +29,15 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. -Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a +Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `frame_bytes_for_payload(n)` is the frame a caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. `serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`. -The default feature set compiles contract, envelope and client only. Schema ids are -still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later -contract version. +The default feature set compiles contract, envelope and client only. The kit speaks v3 +only: a v2 frame gets no answer. ## Verify @@ -42,8 +46,11 @@ cargo test --locked --workspace cargo test --locked --workspace --features model-connector/daemon ``` -`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2 -fixtures in `crates/model-connector/tests/fixtures/wire_v2`. +`crates/model-connector/tests/wire_golden_v3.rs` pins the v3 documents and one sealed carriage +frame to the fixtures in `crates/model-connector/tests/fixtures/wire_v3` (regenerate with +`WIRE_V3_REGENERATE=1`; see the fixtures' README), and +`crates/model-connector/tests/published_schemas.rs` fails when `schemas/` differs from the types +(`MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` rewrites it). ## Provenance and license diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index f004be5..8bd884a 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -25,3 +25,6 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" zeroize = "1" + +[dev-dependencies] +rmpv = "1" diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index ab1e3de..1421a5b 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -6,8 +6,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportKey, TransportResult}; use crate::envelope::{ - MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, - read_transport_frame, write_transport_frame, + MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decode_result_frame, decrypt_result, + encode_invocation_frame, encrypt_invocation, read_transport_frame, write_transport_frame, }; #[derive(Clone)] @@ -51,8 +51,7 @@ impl ConnectorClient { if invocation.request_id().len() > MAX_IDENTITY_BYTES { return Err(ConnectorClientError::RequestIdTooLong); } - let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) - .map_err(|_| ConnectorClientError::Encoding)?; + let request = encode_invocation_frame(&encrypt_invocation(invocation, &self.security)?)?; let connect_timeout = self .response_timeout .unwrap_or(Duration::from_secs(10)) @@ -67,8 +66,7 @@ impl ConnectorClient { .map_err(client_frame_error)?; let response = read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?; - let envelope = - rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?; + let envelope = decode_result_frame(&response)?; decrypt_result(&envelope, &self.security, invocation).map_err(Into::into) } } @@ -120,8 +118,6 @@ pub enum ConnectorClientError { Connection(#[source] std::io::Error), #[error("connector frame exceeded its bound")] FrameSize, - #[error("connector MessagePack encoding failed")] - Encoding, #[error("request id is longer than the connector identity bound")] RequestIdTooLong, #[error(transparent)] @@ -151,7 +147,7 @@ mod fixture_tests { let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let request = read_transport_frame(&mut stream, 64 * 1024).unwrap(); - let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); + let envelope = decode_invocation_frame(&request).unwrap(); assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); let response = encrypt_result( &TransportResult::refused(&expected, Refusal::ModelNotAllowed), @@ -160,7 +156,7 @@ mod fixture_tests { .unwrap(); write_transport_frame( &mut stream, - &rmp_serde::to_vec(&response).unwrap(), + &encode_result_frame(&response).unwrap(), 64 * 1024, ) .unwrap(); @@ -203,4 +199,31 @@ mod fixture_tests { "the client must not even connect" ); } + + #[test] + fn the_client_measures_a_request_id_in_bytes() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = ConnectorClient::new( + listener.local_addr().unwrap(), + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + // 86 characters, 258 bytes: under the bound by characters, over by bytes. + let id = "\u{20AC}".repeat(86); + assert_eq!((id.chars().count(), id.len()), (86, 258)); + let mut long = invocation(); + long.request.request_id = id; + assert!(matches!( + client.execute(&long), + Err(ConnectorClientError::RequestIdTooLong) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "the client must not even connect" + ); + } } diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 5cfffa4..5796d19 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -7,11 +9,11 @@ use zeroize::Zeroize; use crate::envelope::ServiceError; -pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2"; -pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2"; -pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; -pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; -pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; +pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.model.provider_request.v3"; +pub const INVOCATION_SCHEMA_ID: &str = "gamecult.model.transport_invocation.v3"; +pub const RESULT_SCHEMA_ID: &str = "gamecult.model.transport_result.v3"; +pub const RECEIPT_SCHEMA_ID: &str = "gamecult.model.transport_receipt.v3"; +pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.model.transport_envelope.v3"; #[derive(Clone, PartialEq, Eq)] pub struct TransportKey(pub(crate) [u8; 32]); @@ -31,7 +33,8 @@ impl Drop for TransportKey { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ProviderRequest { pub schema_id: String, pub request_id: String, @@ -127,14 +130,14 @@ impl ProviderRequest { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ToolChoice { Auto, Required, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ReasoningEffort { Low, @@ -143,7 +146,7 @@ pub enum ReasoningEffort { Max, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum CacheRetention { Short, @@ -151,7 +154,7 @@ pub enum CacheRetention { } /// Why a provider stopped producing output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum StopReason { EndTurn, @@ -164,7 +167,7 @@ pub enum StopReason { /// What kind of failure a provider reported. `ResultSize` is the connector's own /// stand-in for a result whose sealed frame cannot be carried. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum FailureKind { ResultSize, @@ -179,7 +182,8 @@ pub enum FailureKind { /// Token accounting for one completed request. Cache reads and writes are /// counted apart from plain input; `reasoning_tokens` is absent when the /// provider does not report it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Usage { pub input_tokens: u64, pub cache_read_tokens: u64, @@ -188,7 +192,8 @@ pub struct Usage { pub reasoning_tokens: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum InputItem { UserText { text: String, @@ -207,14 +212,16 @@ pub enum InputItem { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ToolDefinition { pub name: String, pub description: String, pub parameters_json: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportInvocation { pub schema_id: String, pub caller_runtime_id: String, @@ -267,7 +274,8 @@ impl TransportInvocation { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportResult { pub schema_id: String, pub request_id: String, @@ -275,7 +283,8 @@ pub struct TransportResult { pub disposition: TransportDisposition, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportDisposition { Refused(Refusal), Transported { @@ -333,7 +342,8 @@ impl TransportResult { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportEventPayload { TextDelta { text: String, @@ -345,7 +355,8 @@ pub enum TransportEventPayload { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportEvent { pub sequence: u64, pub payload: TransportEventPayload, @@ -368,7 +379,8 @@ impl TransportEvent { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportReceipt { pub schema_id: String, pub request_id: String, @@ -415,7 +427,8 @@ impl TransportReceipt { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportOutcome { Completed { provider_response_id: Option, @@ -428,7 +441,7 @@ pub enum TransportOutcome { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum Refusal { Expired, IdentitySubstitution, @@ -443,11 +456,21 @@ pub enum Refusal { Malformed, } +/// The one wire codec of every v3 document: named MessagePack (struct fields +/// are keyed by name), with unknown fields refused by the types themselves. +pub(crate) fn wire_encode(document: &T) -> Result, ContractError> { + rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding) +} + +pub(crate) fn wire_decode(bytes: &[u8]) -> Result { + rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding) +} + pub fn canonical_provider_request_bytes( request: &ProviderRequest, ) -> Result, ContractError> { request.validate()?; - rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding) + wire_encode(request) } pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> { @@ -461,7 +484,7 @@ pub fn canonical_invocation_bytes( if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? { return Err(ContractError::ProviderDigest); } - rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) + wire_encode(invocation) } pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> { @@ -525,7 +548,7 @@ pub enum ContractError { Expiry, #[error("transport event sequence is not contiguous")] EventSequence, - #[error("canonical MessagePack encoding failed")] + #[error("wire encoding failed")] Encoding, } @@ -601,8 +624,8 @@ mod tests { #[test] fn typed_tool_round_survives_messagepack() { let invocation = invocation(); - let bytes = rmp_serde::to_vec(&invocation).unwrap(); - let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap(); + let bytes = wire_encode(&invocation).unwrap(); + let decoded: TransportInvocation = wire_decode(&bytes).unwrap(); assert_eq!(decoded, invocation); assert!(matches!( decoded.request.tools.as_slice(), @@ -655,4 +678,189 @@ mod tests { Err(ContractError::NativeDigest) ); } + + #[test] + fn v3_documents_carry_their_v3_ids() { + assert_eq!( + [ + PROVIDER_REQUEST_SCHEMA_ID, + INVOCATION_SCHEMA_ID, + RESULT_SCHEMA_ID, + RECEIPT_SCHEMA_ID, + ENVELOPE_SCHEMA_ID + ], + [ + "gamecult.model.provider_request.v3", + "gamecult.model.transport_invocation.v3", + "gamecult.model.transport_result.v3", + "gamecult.model.transport_receipt.v3", + "gamecult.model.transport_envelope.v3" + ] + ); + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { + unreachable!() + }; + assert_eq!(invocation.request.schema_id, PROVIDER_REQUEST_SCHEMA_ID); + assert_eq!(invocation.schema_id, INVOCATION_SCHEMA_ID); + assert_eq!(result.schema_id, RESULT_SCHEMA_ID); + assert_eq!(sent.schema_id, RECEIPT_SCHEMA_ID); + + let v2 = |name: &str| format!("{}{name}.v2", concat!("gamecult.", "codex.")); + let mut request = invocation.request.clone(); + request.schema_id = v2("provider_request"); + assert_eq!(request.validate(), Err(ContractError::Schema)); + let mut stale = invocation.clone(); + stale.schema_id = v2("transport_invocation"); + assert_eq!(stale.validate(1_000, 2_000), Err(ContractError::Schema)); + let mut stale = result.clone(); + stale.schema_id = v2("transport_result"); + assert_eq!( + stale.validate_against(&invocation), + Err(ContractError::Schema) + ); + let mut stale = (**sent).clone(); + stale.schema_id = v2("transport_receipt"); + assert_eq!( + stale.validate_against(&invocation), + Err(ContractError::Schema) + ); + } + + /// `document` re-encoded as a named map with one more key at its top level. + fn with_extra_key(document: &T) -> Vec { + let bytes = wire_encode(document).unwrap(); + let mut value = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap(); + let rmpv::Value::Map(entries) = &mut value else { + panic!("a v3 document is a named map"); + }; + entries.push((rmpv::Value::from("unexpected_field"), rmpv::Value::from(1))); + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, &value).unwrap(); + out + } + + #[test] + fn an_unknown_field_is_refused_not_dropped() { + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { + unreachable!() + }; + let envelope = crate::envelope::encrypt_invocation( + &invocation, + &security("epiphany-distinct-test-key"), + ) + .unwrap(); + assert!(wire_decode::(&wire_encode(&invocation.request).unwrap()).is_ok()); + assert!(wire_decode::(&with_extra_key(&invocation.request)).is_err()); + assert!(wire_decode::(&with_extra_key(&invocation)).is_err()); + assert!(wire_decode::(&with_extra_key(&result)).is_err()); + assert!(wire_decode::(&with_extra_key(&**sent)).is_err()); + assert!( + wire_decode::(&wire_encode(&envelope).unwrap()) + .is_ok() + ); + assert!( + wire_decode::(&with_extra_key(&envelope)).is_err() + ); + } + + #[test] + fn max_output_tokens_is_required() { + let mut request = request(); + assert_eq!(request.validate(), Ok(())); + request.max_output_tokens = 0; + assert_eq!( + request.validate(), + Err(ContractError::Invalid("max_output_tokens")) + ); + } + + #[test] + fn outcomes_are_typed() { + let invocation = invocation(); + for stop_reason in [ + StopReason::EndTurn, + StopReason::ToolUse, + StopReason::MaxOutputTokens, + StopReason::StopSequence, + StopReason::Refusal, + StopReason::ContextWindow, + ] { + let mut sent = receipt(&invocation); + sent.outcome = TransportOutcome::Completed { + provider_response_id: None, + stop_reason, + usage: Usage { + input_tokens: 1, + cache_read_tokens: 2, + cache_write_tokens: 3, + output_tokens: 4, + reasoning_tokens: None, + }, + }; + let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap(); + assert_eq!(decoded, sent); + } + for kind in [ + FailureKind::ResultSize, + FailureKind::InvalidRequest, + FailureKind::Authentication, + FailureKind::RateLimited, + FailureKind::Unavailable, + FailureKind::Timeout, + FailureKind::Provider, + ] { + let mut sent = receipt(&invocation); + sent.outcome = TransportOutcome::Failed { + kind, + message: "failed".to_string(), + }; + assert_eq!(sent.validate_against(&invocation), Ok(())); + let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap(); + assert_eq!(decoded, sent); + } + let mut blank = receipt(&invocation); + blank.outcome = TransportOutcome::Failed { + kind: FailureKind::Provider, + message: String::new(), + }; + assert_eq!( + blank.validate_against(&invocation), + Err(ContractError::Invalid("receipt.failure_message")) + ); + } + + #[test] + fn validate_against_refuses_each_identity_field() { + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + assert_eq!(result.validate_against(&invocation), Ok(())); + let mut other_request = result.clone(); + other_request.request_id = "request-2".to_string(); + let mut other_caller = result.clone(); + other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + for changed in [other_request, other_caller] { + assert_eq!( + changed.validate_against(&invocation), + Err(ContractError::Identity) + ); + } + + let good = receipt(&invocation); + let mut other_request = good.clone(); + other_request.request_id = "request-2".to_string(); + let mut other_caller = good.clone(); + other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + let mut other_model = good.clone(); + other_model.model = "another-model".to_string(); + for changed in [other_request, other_caller, other_model] { + assert_eq!( + changed.validate_against(&invocation), + Err(ContractError::Identity) + ); + } + } } diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 5400eac..0ece953 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -2,83 +2,256 @@ use std::io::{Read, Write}; use aes_gcm::aead::{Aead, Payload}; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultnet_rs::{ + CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, +}; use rand_core::{OsRng, RngCore}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::contract::{ ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult, - canonical_invocation_bytes, require_id, + canonical_invocation_bytes, require_id, wire_decode, wire_encode, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum EnvelopeKind { Invocation, Result, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportEnvelope { pub schema_id: String, pub caller_runtime_id: String, pub request_id: String, pub message_kind: EnvelopeKind, pub nonce: [u8; 12], + #[serde(with = "serde_bytes")] + #[schemars(with = "Vec")] pub ciphertext: Vec, } +/// The CultNet service every frame of this connector addresses. +pub const CONNECTOR_SERVICE_ID: &str = "gamecult.model.connector"; +/// The one operation of that service: run one sealed invocation. +pub const INVOKE_OPERATION: &str = "invoke"; + +const PAYLOAD_ENCODING: &str = "messagepack-base64"; +const OK_STATUS: &str = "ok"; + /// The longest caller id, request id or model name a service admits. The /// frame arithmetic below depends on it. A request whose id is longer is /// refused on the wire as malformed. pub const MAX_IDENTITY_BYTES: usize = 256; -/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext -/// bytes can occupy, with ids at their longest: the frame a caller payload -/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext -/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array -/// of integers: a byte of 128 or more takes two bytes, so the worst case is -/// twice the ciphertext, plus the array header and the envelope's other -/// fields at their longest. Measured from the real encoding, not assumed. +fn carriage_bytes(message: &CultNetMessage) -> Result, ServiceError> { + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0) + .map_err(|_| ServiceError::Carriage) +} + +fn seal_payload(envelope: &TransportEnvelope) -> Result { + Ok(STANDARD.encode(wire_encode(envelope)?)) +} + +/// The CultNet frame an invocation travels in: a `cultnet.operation_request.v0` +/// for [`CONNECTOR_SERVICE_ID`] and [`INVOKE_OPERATION`], its `message_id` the +/// request id and its payload the base64 of the named sealed envelope. +pub(crate) fn encode_invocation_frame( + envelope: &TransportEnvelope, +) -> Result, ServiceError> { + carriage_bytes(&CultNetMessage::OperationRequest { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: seal_payload(envelope)?, + source_runtime_id: None, + target_runtime_id: None, + }) +} + +/// The CultNet frame a result travels in: a `cultnet.operation_response.v0` +/// with status `ok`, otherwise shaped like the invocation's. +pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result, ServiceError> { + carriage_bytes(&CultNetMessage::OperationResponse { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + status: OK_STATUS.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: seal_payload(envelope)?, + diagnostics: Vec::new(), + source_runtime_id: None, + }) +} + +/// Opens the carriage of one frame. Everything the carriage says is checked +/// here, before any decryption: the service, the operation, the payload schema, +/// and that the message id is the sealed envelope's request id. +fn open_carriage( + message_id: &str, + service_id: &str, + operation: &str, + payload_schema: &str, + payload: &str, +) -> Result { + if service_id != CONNECTOR_SERVICE_ID + || operation != INVOKE_OPERATION + || payload_schema != ENVELOPE_SCHEMA_ID + { + return Err(ServiceError::Carriage); + } + let bytes = STANDARD + .decode(payload) + .map_err(|_| ServiceError::Carriage)?; + let envelope: TransportEnvelope = wire_decode(&bytes).map_err(|_| ServiceError::Encoding)?; + if envelope.request_id != message_id { + return Err(ServiceError::OuterIdentity); + } + Ok(envelope) +} + +fn read_carriage(bytes: &[u8]) -> Result { + decode_cultnet_message_from_slice(bytes, CultNetWireContract::CultNetSchemaV0) + .map_err(|_| ServiceError::Carriage) +} + +pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result { + match read_carriage(bytes)? { + CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload, + .. + } => open_carriage( + &message_id, + &service_id, + &operation, + &payload_schema, + &payload, + ), + _ => Err(ServiceError::Carriage), + } +} + +pub(crate) fn decode_result_frame(bytes: &[u8]) -> Result { + match read_carriage(bytes)? { + CultNetMessage::OperationResponse { + message_id, + service_id, + operation, + status, + payload_schema, + payload, + .. + } if status == OK_STATUS => open_carriage( + &message_id, + &service_id, + &operation, + &payload_schema, + &payload, + ), + _ => Err(ServiceError::Carriage), + } +} + +/// The most bytes one frame holding a sealed envelope of `ciphertext_len` +/// ciphertext bytes can occupy, with ids at their longest: the frame a caller +/// payload bound (`CallerAdmission`'s `max_payload_bytes`, a bound on +/// ciphertext length) needs. +/// +/// A frame is a CultNet operation message whose payload is the base64 of the +/// named envelope. The result frame is the larger of the two directions (it +/// also carries a status), so this bound holds for an invocation as well. The +/// envelope shell and the message shell are measured from their real +/// encodings; only the MessagePack length headers and base64 expansion that +/// grow with `ciphertext_len` are arithmetic. pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { - let shell = rmp_serde::to_vec(&TransportEnvelope { + let long = "x".repeat(MAX_IDENTITY_BYTES); + let envelope_shell = wire_encode(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), - caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), - request_id: "x".repeat(MAX_IDENTITY_BYTES), + caller_runtime_id: long.clone(), + request_id: long.clone(), message_kind: EnvelopeKind::Invocation, nonce: [0xff; 12], ciphertext: Vec::new(), }) .map_or(usize::MAX, |bytes| bytes.len()); - // The shell holds an empty array header of one byte. - let header = match ciphertext_len { - 0..=15 => 1, - 16..=65_535 => 3, + let message_shell = encode_cultnet_message_to_vec( + &CultNetMessage::OperationResponse { + message_id: long, + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + status: OK_STATUS.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: "A".to_string(), + diagnostics: Vec::new(), + source_runtime_id: None, + }, + CultNetWireContract::CultNetSchemaV0, + ) + .map_or(usize::MAX, |bytes| bytes.len()); + // The shell holds an empty `bin 8`: a marker and a zero length. The shell's + // one-character payload takes a one-byte string header and the character. + let envelope = envelope_shell + .saturating_sub(2) + .saturating_add(bin_header_bytes(ciphertext_len)) + .saturating_add(ciphertext_len); + let payload = envelope.div_ceil(3).saturating_mul(4); + message_shell + .saturating_sub(2) + .saturating_add(str_header_bytes(payload)) + .saturating_add(payload) +} + +/// MessagePack `bin` header width for a byte string of `len` bytes. +fn bin_header_bytes(len: usize) -> usize { + match len { + 0..=255 => 2, + 256..=65_535 => 3, _ => 5, - }; - shell - .saturating_sub(1) - .saturating_add(header) - .saturating_add(ciphertext_len.saturating_mul(2)) + } +} + +/// MessagePack `str` header width for a string of `len` bytes. +fn str_header_bytes(len: usize) -> usize { + match len { + 0..=31 => 1, + 32..=255 => 2, + 256..=65_535 => 3, + _ => 5, + } } /// The largest payload bound whose worst-case frame fits `max_frame_bytes`: /// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot /// carry even an empty envelope. pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { - // frame_bytes_for_payload(0) is the shell plus a one-byte array header. - let fixed = frame_bytes_for_payload(0) - 1; - let room = max_frame_bytes.checked_sub(fixed)?; - // Each array header width bounds the payloads it can describe; the - // widest payload any width admits is the answer. - let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap)); - [ - by_header(5, usize::MAX), - by_header(3, 65_535), - by_header(1, 15), - ] - .into_iter() - .flatten() - .max() + if frame_bytes_for_payload(0) > max_frame_bytes { + return None; + } + // A frame is longer than its payload, so the answer is below the bound. + let (mut fits, mut over) = (0, max_frame_bytes.saturating_add(1)); + while over - fits > 1 { + let middle = fits + (over - fits) / 2; + if frame_bytes_for_payload(middle) <= max_frame_bytes { + fits = middle; + } else { + over = middle; + } + } + Some(fits) } pub fn encrypt_invocation( @@ -102,7 +275,7 @@ pub fn decrypt_invocation( validate_envelope(envelope, EnvelopeKind::Invocation)?; let plaintext = decrypt_message(envelope, security)?; let invocation: TransportInvocation = - rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?; if invocation.caller_runtime_id != envelope.caller_runtime_id || invocation.request_id() != envelope.request_id { @@ -115,7 +288,7 @@ pub fn encrypt_result( result: &TransportResult, security: &TransportKey, ) -> Result { - let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?; + let plaintext = wire_encode(result).map_err(|_| ServiceError::Encoding)?; encrypt_message( &result.caller_runtime_id, &result.request_id, @@ -132,8 +305,7 @@ pub fn decrypt_result( ) -> Result { validate_envelope(envelope, EnvelopeKind::Result)?; let plaintext = decrypt_message(envelope, security)?; - let result: TransportResult = - rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + let result: TransportResult = wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?; if result.caller_runtime_id != envelope.caller_runtime_id || result.request_id != envelope.request_id { @@ -242,13 +414,15 @@ pub(crate) fn decrypt_message( .map_err(|_| ServiceError::Encryption) } +/// The authenticated data of a sealed message: a positional tuple, a MAC input +/// and not a wire document (a tuple is positional under the named codec too). pub(crate) fn envelope_aad( schema_id: &str, caller_runtime_id: &str, request_id: &str, message_kind: EnvelopeKind, ) -> Result, ServiceError> { - rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind)) + wire_encode(&(schema_id, caller_runtime_id, request_id, message_kind)) .map_err(|_| ServiceError::Encoding) } @@ -291,8 +465,10 @@ pub enum ServiceError { OuterIdentity, #[error("transport encryption or authentication failed")] Encryption, - #[error("transport MessagePack encoding failed")] + #[error("transport encoding failed")] Encoding, + #[error("frame is not a connector operation message")] + Carriage, #[error("completion did not carry a transported provider outcome")] InvalidCompletion, #[error("completion has no active request")] @@ -373,7 +549,7 @@ mod tests { let key = security("epiphany-distinct-test-key"); let invocation = invocation(); let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); - let plaintext = rmp_serde::to_vec(&result).unwrap(); + let plaintext = wire_encode(&result).unwrap(); let seal = |caller: &str, request_id: &str| { encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() }; @@ -401,7 +577,7 @@ mod tests { let good = encrypt_invocation(&invocation, &key).unwrap(); let mut schema = good.clone(); - schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string(); + schema.schema_id = concat!("gamecult.", "codex.transport_envelope.v2").to_string(); let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result); let mut empty = good.clone(); empty.ciphertext.clear(); @@ -452,20 +628,44 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } - #[test] - fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { + /// The real result frame for an envelope with ids at the longest admitted + /// and a ciphertext of `length` bytes. + fn real_worst_frame(length: usize) -> Vec { let long = "x".repeat(MAX_IDENTITY_BYTES); - for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] { - let envelope = TransportEnvelope { - schema_id: ENVELOPE_SCHEMA_ID.to_string(), - caller_runtime_id: long.clone(), - request_id: long.clone(), - message_kind: EnvelopeKind::Invocation, - nonce: [0xff; 12], - ciphertext: vec![0xff; length], - }; + encode_result_frame(&TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: long.clone(), + request_id: long, + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: vec![0xff; length], + }) + .unwrap() + } + + /// Ciphertext sizes either side of every length-header boundary the frame + /// crosses: bin 8 to 16 to 32, str 8 to 16 to 32, and each base64 padding. + fn boundary_sizes() -> Vec { + let mut sizes: Vec = (0..=40).collect(); + for center in [ + 100, 200, 250, 256, 300, 1000, 3000, 65_535, 65_536, 49_000, 49_100, 49_152, 49_200, + 70_000, + ] { + for delta in 0..=40 { + sizes.push(center + delta); + sizes.push(center.saturating_sub(delta)); + } + } + sizes.sort_unstable(); + sizes.dedup(); + sizes + } + + #[test] + fn the_frame_bound_is_the_real_carriage_encoding() { + for length in boundary_sizes() { assert_eq!( - rmp_serde::to_vec(&envelope).unwrap().len(), + real_worst_frame(length).len(), frame_bytes_for_payload(length), "{length}" ); @@ -480,16 +680,18 @@ mod tests { let sealed = encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) .unwrap(); - let real = rmp_serde::to_vec(&sealed).unwrap().len(); let worst = frame_bytes_for_payload(sealed.ciphertext.len()); - assert!(real <= worst, "{real} > {worst}"); - // Real ciphertext is random, so about half its bytes take two - // bytes: the worst case is not wildly above what is written. + for real in [ + encode_result_frame(&sealed).unwrap().len(), + encode_invocation_frame(&sealed).unwrap().len(), + ] { + assert!(real <= worst, "{real} > {worst}"); + } + // The frame is the base64 of the envelope: its worst case is + // within the base64 expansion of the real one. if id.len() == MAX_IDENTITY_BYTES { - assert!( - worst <= real + sealed.ciphertext.len() + 64, - "{worst} {real}" - ); + let real = encode_result_frame(&sealed).unwrap().len(); + assert!(worst <= real + 8, "{worst} {real}"); } } } @@ -502,7 +704,7 @@ mod tests { None ); assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); - for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { + for bytes in boundary_sizes().into_iter().chain([2_000_000]) { let frame = frame_bytes_for_payload(bytes); assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); assert!( @@ -511,4 +713,199 @@ mod tests { ); } } + + #[test] + fn ciphertext_travels_as_binary() { + let key = security("epiphany-distinct-test-key"); + for plaintext in [10, 300, 70_000] { + let sealed = encrypt_message( + "caller", + "request", + EnvelopeKind::Invocation, + &vec![0x41; plaintext], + &key, + ) + .unwrap(); + let length = sealed.ciphertext.len(); + let mut expected = match length { + 0..=255 => vec![0xc4, length as u8], + 256..=65_535 => [&[0xc5][..], &(length as u16).to_be_bytes()].concat(), + _ => [&[0xc6][..], &(length as u32).to_be_bytes()].concat(), + }; + expected.extend(&sealed.ciphertext); + let encoded = wire_encode(&sealed).unwrap(); + assert!( + encoded + .windows(expected.len()) + .any(|window| window == expected), + "{plaintext}" + ); + } + } + + fn sealed_for_carriage() -> TransportEnvelope { + encrypt_invocation(&invocation(), &security("epiphany-distinct-test-key")).unwrap() + } + + fn request_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec { + let envelope = sealed_for_carriage(); + let mut message = decode_cultnet_message_from_slice( + &encode_invocation_frame(&envelope).unwrap(), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + edit(&mut message); + carriage_bytes(&message).unwrap() + } + + fn response_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec { + let envelope = sealed_for_carriage(); + let mut message = decode_cultnet_message_from_slice( + &encode_result_frame(&envelope).unwrap(), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + edit(&mut message); + carriage_bytes(&message).unwrap() + } + + #[test] + fn the_carriage_round_trips_both_directions() { + let envelope = sealed_for_carriage(); + assert_eq!( + decode_invocation_frame(&encode_invocation_frame(&envelope).unwrap()).unwrap(), + envelope + ); + assert_eq!( + decode_result_frame(&encode_result_frame(&envelope).unwrap()).unwrap(), + envelope + ); + // A request is not a response, in either direction. + assert_eq!( + decode_result_frame(&encode_invocation_frame(&envelope).unwrap()), + Err(ServiceError::Carriage) + ); + assert_eq!( + decode_invocation_frame(&encode_result_frame(&envelope).unwrap()), + Err(ServiceError::Carriage) + ); + } + + #[test] + fn the_carriage_refuses_another_service_operation_or_schema() { + fn edit_request(field: &str, value: &str) -> Vec { + request_message(|message| { + if let CultNetMessage::OperationRequest { + service_id, + operation, + payload_schema, + .. + } = message + { + match field { + "service" => *service_id = value.to_string(), + "operation" => *operation = value.to_string(), + _ => *payload_schema = value.to_string(), + } + } + }) + } + fn edit_response(field: &str, value: &str) -> Vec { + response_message(|message| { + if let CultNetMessage::OperationResponse { + service_id, + operation, + payload_schema, + status, + .. + } = message + { + match field { + "service" => *service_id = value.to_string(), + "operation" => *operation = value.to_string(), + "status" => *status = value.to_string(), + _ => *payload_schema = value.to_string(), + } + } + }) + } + for (field, value) in [ + ("service", "gamecult.model.other"), + ("operation", "cancel"), + ("schema", "gamecult.model.transport_envelope.v2"), + ] { + assert_eq!( + decode_invocation_frame(&edit_request(field, value)), + Err(ServiceError::Carriage), + "{field}" + ); + assert_eq!( + decode_result_frame(&edit_response(field, value)), + Err(ServiceError::Carriage), + "{field}" + ); + } + assert_eq!( + decode_result_frame(&edit_response("status", "rejected")), + Err(ServiceError::Carriage) + ); + // The unedited message decodes, so each refusal above is the edit's. + assert!(decode_invocation_frame(&edit_request("service", CONNECTOR_SERVICE_ID)).is_ok()); + assert!(decode_result_frame(&edit_response("status", "ok")).is_ok()); + } + + #[test] + fn the_carriage_refuses_a_message_id_that_is_not_the_envelopes_request_id() { + let request = request_message(|message| { + if let CultNetMessage::OperationRequest { message_id, .. } = message { + *message_id = "another-request".to_string(); + } + }); + let response = response_message(|message| { + if let CultNetMessage::OperationResponse { message_id, .. } = message { + *message_id = "another-request".to_string(); + } + }); + assert_eq!( + decode_invocation_frame(&request), + Err(ServiceError::OuterIdentity) + ); + assert_eq!( + decode_result_frame(&response), + Err(ServiceError::OuterIdentity) + ); + } + + #[test] + fn the_carriage_refuses_what_is_not_a_connector_message() { + let good = encode_invocation_frame(&sealed_for_carriage()).unwrap(); + // Not MessagePack of any CultNet message, nor a truncated one. + assert_eq!( + decode_invocation_frame(&[0xc1, 0x00, 0x01]), + Err(ServiceError::Carriage) + ); + assert_eq!( + decode_invocation_frame(&good[..good.len() / 2]), + Err(ServiceError::Carriage) + ); + // A payload that is not base64, and base64 of something else. + let not_base64 = request_message(|message| { + if let CultNetMessage::OperationRequest { payload, .. } = message { + *payload = "!!not base64!!".to_string(); + } + }); + assert_eq!( + decode_invocation_frame(¬_base64), + Err(ServiceError::Carriage) + ); + let not_an_envelope = request_message(|message| { + if let CultNetMessage::OperationRequest { payload, .. } = message { + *payload = STANDARD.encode(b"\x01\x02"); + } + }); + assert_eq!( + decode_invocation_frame(¬_an_envelope), + Err(ServiceError::Encoding) + ); + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 7ca0a3a..cef686a 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,8 +12,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload, - read_transport_frame, write_transport_frame, + ServiceError, TransportFrameError, decode_invocation_frame, encode_result_frame, + frame_bytes_for_payload, read_transport_frame, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -222,8 +222,7 @@ fn execute_inference_frame( backend: &dyn ProviderBackend, limits: &ServeLimits, ) -> Result, ServeError> { - let envelope: TransportEnvelope = - rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; + let envelope = decode_invocation_frame(request).map_err(|_| ServeError::FrameEncoding)?; let admission = service .lock() .map_err(|_| ServeError::ServicePoisoned)? @@ -238,7 +237,7 @@ fn execute_inference_frame( .complete(*claim, result, limits.max_frame_bytes)? } }; - rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) + encode_result_frame(&response).map_err(|_| ServeError::FrameEncoding) } fn frame_error(error: TransportFrameError) -> ServeError { @@ -314,7 +313,7 @@ pub enum ServeError { Connection(#[source] std::io::Error), #[error("connector frame exceeded its bound")] FrameSize, - #[error("connector frame was not valid MessagePack")] + #[error("connector frame was not a connector operation message")] FrameEncoding, #[error("system clock is before the Unix epoch")] Clock, @@ -506,7 +505,7 @@ mod tests { let backend = CountingBackend::new(true); let (_client, endpoint) = start(backend.clone(), &store); let security = crate::fixtures::security(KEY); - let request = rmp_serde::to_vec( + let request = crate::envelope::encode_invocation_frame( &crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(), ) .unwrap(); @@ -531,14 +530,14 @@ mod tests { // requires the result to name the request by its own id. let send = |id: &str| { let invocation = fresh(id); - let request = rmp_serde::to_vec( + let request = crate::envelope::encode_invocation_frame( &crate::envelope::encrypt_invocation(&invocation, &security).unwrap(), ) .unwrap(); let mut stream = TcpStream::connect(endpoint).unwrap(); write_transport_frame(&mut stream, &request, FRAME).unwrap(); let reply = read_transport_frame(&mut stream, FRAME).unwrap(); - let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); + let envelope = crate::envelope::decode_result_frame(&reply).unwrap(); crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap() }; let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); @@ -684,7 +683,7 @@ mod tests { #[test] fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); - let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure( + let plaintext = crate::contract::wire_encode(&crate::service::result_size_failure( &long, &long, &long, [0xff; 32], [0xff; 32], )) .unwrap() @@ -963,7 +962,7 @@ mod tests { } fn encoded_len(result: &TransportResult) -> usize { - rmp_serde::to_vec(result).unwrap().len() + crate::contract::wire_encode(result).unwrap().len() } /// Two calls with one request id, through a live serve loop. @@ -1154,4 +1153,92 @@ mod tests { client.execute(&fresh("request-key")).unwrap(); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + + #[test] + fn a_v2_frame_gets_no_answer() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, endpoint) = start(backend.clone(), &store); + // The v2 envelope: positional, an integer-array ciphertext, a v2 id. + let v2 = rmp_serde::to_vec(&( + concat!("gamecult.", "codex.transport_envelope.v2"), + CALLER, + "request-v2", + "Invocation", + [0_u8; 12], + vec![1_u8; 64], + )) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &v2, FRAME).unwrap(); + assert!(matches!( + read_transport_frame(&mut stream, FRAME), + Err(TransportFrameError::Connection(_)) + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + // The loop survives the v2 peer and serves a v3 one. + client.execute(&fresh("request-after-v2")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_reply_echoes_the_request_id() { + use cultnet_rs::{CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice}; + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let send = |invocation: &TransportInvocation| { + let request = crate::envelope::encode_invocation_frame( + &crate::envelope::encrypt_invocation(invocation, &security).unwrap(), + ) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + let reply = read_transport_frame(&mut stream, FRAME).unwrap(); + let CultNetMessage::OperationResponse { + message_id, status, .. + } = decode_cultnet_message_from_slice(&reply, CultNetWireContract::CultNetSchemaV0) + .unwrap() + else { + panic!("a reply is an operation response"); + }; + let envelope = crate::envelope::decode_result_frame(&reply).unwrap(); + let result = crate::envelope::decrypt_result(&envelope, &security, invocation).unwrap(); + (message_id, status, result) + }; + + let transported = fresh("request-echo-transported"); + let (message_id, status, result) = send(&transported); + assert_eq!( + ( + message_id.as_str(), + status.as_str(), + result.request_id.as_str() + ), + ("request-echo-transported", "ok", "request-echo-transported") + ); + assert!(matches!( + result.disposition, + TransportDisposition::Transported { .. } + )); + + let mut refused = fresh("request-echo-refused"); + refused.request.model = "unlisted-model".to_string(); + refused.provider_request_sha256 = + crate::contract::provider_request_sha256(&refused.request).unwrap(); + let (message_id, status, result) = send(&refused); + assert_eq!( + ( + message_id.as_str(), + status.as_str(), + result.request_id.as_str() + ), + ("request-echo-refused", "ok", "request-echo-refused") + ); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::ModelNotAllowed) + ); + } } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 44c3fe1..75ac66f 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -13,7 +13,7 @@ use crate::contract::{ }; use crate::envelope::{ EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, - encrypt_result, validate_envelope, + encode_result_frame, encrypt_result, validate_envelope, }; pub struct CallerAdmission { @@ -90,7 +90,7 @@ enum ReplayState { } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "gamecult.codex.replay_record.v1")] +#[cultcache(type = "gamecult.model.replay_record.v1")] struct ReplayRecord { #[cultcache(key = 0)] caller_runtime_id: String, @@ -313,8 +313,8 @@ impl TransportService { }))) } - /// Records a result and seals it. `max_frame_bytes` bounds the serialized - /// envelope, which is what a frame carries: a result whose sealed envelope + /// Records a result and seals it. `max_frame_bytes` bounds the frame the + /// sealed envelope travels in (its carriage): a result whose sealed envelope /// does not fit is recorded as the typed `result_size` failure instead, so /// no completed record is ever undeliverable and a retry replays bytes /// that fit. @@ -424,9 +424,7 @@ impl TransportService { } fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result { - rmp_serde::to_vec(envelope) - .map(|bytes| bytes.len() <= max_frame_bytes) - .map_err(|_| ServiceError::Encoding) + encode_result_frame(envelope).map(|bytes| bytes.len() <= max_frame_bytes) } /// The provider named on the stand-in failure, fixed so its size is bounded. @@ -761,7 +759,7 @@ mod tests { &invocation.caller_runtime_id, invocation.request_id(), EnvelopeKind::Invocation, - &rmp_serde::to_vec(invocation).unwrap(), + &crate::contract::wire_encode(invocation).unwrap(), &security(KEY), ) .unwrap() @@ -1007,7 +1005,8 @@ mod tests { }; assert_eq!(reply.request_id, id, "{length}"); let result: TransportResult = - rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); + crate::contract::wire_decode(&decrypt_message(&reply, &security(KEY)).unwrap()) + .unwrap(); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed) @@ -1053,7 +1052,7 @@ mod tests { fn a_frame_check_is_inclusive_of_the_serialized_envelope() { let envelope = encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap(); - let size = rmp_serde::to_vec(&envelope).unwrap().len(); + let size = encode_result_frame(&envelope).unwrap().len(); assert_eq!(fits_frame(&envelope, size), Ok(true)); assert_eq!(fits_frame(&envelope, size - 1), Ok(false)); } @@ -1098,7 +1097,7 @@ mod tests { let (claim, invocation) = claim_for(&mut service, "stand-in"); let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); let response = service.complete(*claim, big, 2000).unwrap(); - assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000); + assert!(encode_result_frame(&response).unwrap().len() <= 2000); let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); assert_eq!( stand_in, @@ -1172,7 +1171,7 @@ mod tests { let (claim, invocation) = claim_for(&mut service, "restart"); let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); let recorded = service.complete(*claim, big, FRAME).unwrap(); - assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000); + assert!(encode_result_frame(&recorded).unwrap().len() > 2000); drop(service); let mut service = open_service(&store, 8); @@ -1192,7 +1191,7 @@ mod tests { else { panic!("expected a replay"); }; - assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000); + assert!(encode_result_frame(&smaller).unwrap().len() <= 2000); assert_eq!( decrypt_result(&smaller, &security(KEY), &invocation).unwrap(), result_size_failure( @@ -1251,4 +1250,25 @@ mod tests { TransportDisposition::Refused(Refusal::Indeterminate) ); } + + #[test] + fn contract_refusal_maps_each_error() { + assert_eq!(contract_refusal(&ContractError::Expiry), Refusal::Expired); + assert_eq!( + contract_refusal(&ContractError::Identity), + Refusal::IdentitySubstitution + ); + assert_eq!( + contract_refusal(&ContractError::ProviderDigest), + Refusal::ProviderDigestSubstitution + ); + for other in [ + ContractError::Schema, + ContractError::Invalid("model"), + ContractError::NativeDigest, + ContractError::EventSequence, + ] { + assert_eq!(contract_refusal(&other), Refusal::Malformed, "{other:?}"); + } + } } diff --git a/crates/model-connector/tests/fixtures/wire_v3/README.md b/crates/model-connector/tests/fixtures/wire_v3/README.md new file mode 100644 index 0000000..918df33 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/README.md @@ -0,0 +1,15 @@ +# wire_v3 fixtures + +The named-MessagePack bytes of the v3 documents and one sealed carriage frame, +produced by the kit at the revision that added them. They pin drift; the types in +`src/contract.rs` own the shape, and `schemas/` publishes it. + +- `provider_request.mp`, `transport_invocation.mp`, `transport_result_transported.mp`, + `transport_result_refused.mp`, `transport_receipt_failed.mp`, `transport_envelope.mp`: + the plaintext documents. +- `carriage_invocation.frame`: a `cultnet.operation_request.v0` for service + `gamecult.model.connector`, operation `invoke`, whose payload is the base64 of an envelope + sealed under the fixed test key in `tests/wire_golden_v3.rs`. + +Generator: `tests/wire_golden_v3.rs` itself. Run it with `WIRE_V3_REGENERATE=1` to rewrite +the files from the kit, then review the diff: a changed byte is a wire change. diff --git a/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame new file mode 100644 index 0000000..6051b64 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame @@ -0,0 +1 @@ +‡©messageId®golden-request©operation¦invoke§payloadÚ$hqlzY2hlbWFfaWTZJGdhbWVjdWx0Lm1vZGVsLnRyYW5zcG9ydF9lbnZlbG9wZS52M7FjYWxsZXJfcnVudGltZV9pZK1nb2xkZW4tY2FsbGVyqnJlcXVlc3RfaWSuZ29sZGVuLXJlcXVlc3SsbWVzc2FnZV9raW5kqkludm9jYXRpb26lbm9uY2WcS2XMwjgRzMrM9QTM037MsczmqmNpcGhlcnRleHTFAy8eYmQGV5JCOLETwqQE+wIz/6yyPltHTPywbakMD/mgkJUxAvKWLNljXa4RAVVITAb3GxklnGF8zoOX/9qGSM9r0JZ126s+0LqvsRNhllG1S0pfG49fQJy99pevHReOSvX/cNWs2cHppYNdJUbVVAkDuioEXwtEs/vRCsmD/ab0bKUX0XRzrNbmjMU4+dHUCus8yt+iNTK9GdjXbEQAweeKNehirXEgnngLnwylo/MDoI4G237du3PmwhMzyIHztoIbwD0Bv9x7WqY+jj90vqEBsOHl+FmYc+F8+OiP6NKTDNRHypUtdo32G/IgfMfbsP/XHiNpPHtLxDqZVIcYdYqLAvhDfLBReLzQVLuNJnE0a2P8xLVAkfbmQZNsxRqS14RjYVtRoDcacxH1h0afo9OWMSK8Sp8eEBAhAJ7ovhCMklc7xWB3o4ny4cmDSExx5zWRLdI14rC1CZ7nq8k3V8fIIHctSp6am35A5yyiTwDH0m3h7/hvXrzhOqtAwHd0jkfNv+qOXZCEPdtbzOXOz0fMVZwvTAJWhoEYmhfKcGvXu8SmEtZcZe0WjF4xpFIFCFsSxh8HcrDFvxKDa58bnimo6DE2VeEMolpaBvnIGEst6qNZ9VQQnQIpCGbbtFd7jMrncnfcq+36NI10qW30r8sqAqgORp8cAGP5fy2X9SQDmH13SXgSfIdr7uxAHhK0X1r0iopJn4XtVfBiTd+IfoLFN+KO/JTZvLc48e6Jl9QRnxkL/UCgT/5wENd9VRxP+oO1CXG3DthI882rVbZDkhhwxupM9J/EURONajVyVSkeju+r9vzHCgR5odRW8cwqY1RXoq6L6mhj4ph01MBkUugz8MFwBsem+8QpnGatyVbZB99knWjK/01o5J6SZihcXNowRdon6BTydFByJaV5bRvgIK7IGRct3BmC8xidyxN9p7QAZYMPscxhQ0hAM615w76Cy82kx6eAzREBQX3COKApXSlPA/xlOKa/iUUMWHvFKyf/n0KrOlVvHjoDzPflHi3hpxxWcQGt8CxxNdSQMufeqce/5/BCeEwRIJ99+k7CEqIqpFIs7cAsI1GpF+NO5w==¯payloadEncoding²messagepack-base64­payloadSchemaÙ$gamecult.model.transport_envelope.v3­schemaVersion¼cultnet.operation_request.v0©serviceId¸gamecult.model.connector \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/provider_request.mp b/crates/model-connector/tests/fixtures/wire_v3/provider_request.mp new file mode 100644 index 0000000000000000000000000000000000000000..9ff75e4ce83891c17b6d223ad6da896c4ecc0ae5 GIT binary patch literal 549 zcmZuuy-wsX5WYu=(q|=fM?=dCpcPkJK6^+Ejwg7=D-fbss3{O{fOcVF5rLzDnwkgT zZEn1zKnIO&&CmDevl}uEv|2g)Ue>w=lRS@FwBScw#EG*IRfPQn#G}d2+u22N{ueeVB+q}ZW!o7f;MlkRoAcVn%TEWf^MxHgUZhE67Ny&-@ zQmo9Wk=C3Amd;PVurDo;iLT3|FJn5Y0w#1{DLz9=!VK%9~#Qe%MgMhkpmxiqIlFE>9WHAkCOE(`pfA{qdGnXI$^iWmh literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp new file mode 100644 index 0000000000000000000000000000000000000000..95447869386911a8ebd04061ff2f8126e337e8a1 GIT binary patch literal 799 zcmah{ziSjh6h0emv`@KQVRr}qh(%J`X>%OWN*KnOm)(iGGwaNo<1|D#gMxxZuuK)b zOD;Lhc?qI+f{n$9wPIuGpCE$x=I#RF6x?EWnK$qI-uHd);jR-I6r3sfJ)%wI-AnFblUmIAkv4=jShdt0brX^SmjoX(<_LXIrlncf)@8dM5k|kHh!y zGi+QBzmxE>F=rOTW_X|8I~g8a4UZPXR`_vxVP^{PWxV>XNmC_8L&9aDa4cp#VtrV0OJgG$%T|bUv|5d> zR2Qn|W|UoB8$<#oGNT0aV-~b@@-Vj1dgg3WrGNL~U?PJ}SN-2`qk^~D^dg#DpxvjW F@&{N|N`(Lb literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp new file mode 100644 index 0000000000000000000000000000000000000000..4e0f1b1e9fd852297b60da82763d620e0cd9e8d0 GIT binary patch literal 358 zcmaivu`dKc6vkCF^vX%py9G|+dIgP2yP3?q>`iWF-kN#4mnbA85_D>n1cz`!A_|RC z@%{m#QTh`^B9_~0G+z0Azwej4-SenA;ACd>^Gq%ssM1pEP=ie=NFNIflm$>=3QDWF ziva5-M5_5gj#h(Dn&d`FYawKz^kf{=z*Pebi#spLR8YHyg4mI>t%cVyG4apc7Xd4y zf7E}c%Wi`Q{4&L#yw7j^!3S&nCHQG|8ryuxkNM^}@2v1%n~(T?a`EK%uWMyxsUg9l zjLCe`Ive?KrKe87!l=YSj53GV$C4B}*g9R3#)95H?i#NzgCxkTafU$m8}?zO5i)i` N3qlbs_Jqcs{|1!MkQM*{ literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp new file mode 100644 index 0000000..0c4f7b1 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp @@ -0,0 +1 @@ +„©schema_idÙ"gamecult.model.transport_result.v3ªrequest_id®golden-request±caller_runtime_id­golden-caller«disposition�§Refused«OutputBound \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp new file mode 100644 index 0000000000000000000000000000000000000000..1922073e7fd0adc6f1bbfa370f1fbcb1cef3e569 GIT binary patch literal 715 zcmbu7F>e$x5QRen6rraK(9moo0-+#fsGy-Z(XOoZXwQo6F`n@eKps-St^paeb7 zURZMIx%5C;?p=KD;Z^|Rgzwj=CD@`>UROeE@TCuym4O88wZP=ZSgPbjiWS=axHGi| zxwZ>EuuM=)d05cP7p#`-YV zOjp>=-(q3MriYC^2w4&aU%^QDI+$Gb*}eNi4;3iKkA92d++U(Ng#Rmxw^p#~L5g(} zCUwQa;^ogXb0_|>-Z_swC3DPw9=&Gn#{Kx|RQw(v$1m|)d~!4Xn2R57oW`ZN72nqn zj>nDbadRnd$FH+j28S(vS!od@6zBpKmx{$^>VF_CcRe~)6$Xez4K&2S6&mP|meDv3 z408R=l+P35&{FREz))RW7!#vQvLLh#r^b|lU=Xs^#tUU#;F56#EUl)=Nh6q1*-GaR aCem%M7+US*UaEg|>ddQ&bsB{hnEeR=ZYo#+ literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/published_schemas.rs b/crates/model-connector/tests/published_schemas.rs new file mode 100644 index 0000000..1dabe18 --- /dev/null +++ b/crates/model-connector/tests/published_schemas.rs @@ -0,0 +1,66 @@ +//! `schemas/` at the repository root publishes one JSON Schema per v3 document, +//! generated from the types. This test regenerates them and fails on any +//! difference; run it with `MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` to rewrite the +//! files from the types. + +use std::path::{Path, PathBuf}; + +use model_connector::*; +use schemars::{JsonSchema, schema_for}; + +const REGENERATE: &str = "MODEL_CONNECTOR_REGENERATE_SCHEMAS"; + +fn render() -> String { + let mut text = serde_json::to_string_pretty(&schema_for!(T)).unwrap(); + text.push('\n'); + text +} + +fn schemas_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas") +} + +#[test] +fn published_schemas_are_the_types() { + let documents = [ + ("provider_request", render::()), + ("transport_invocation", render::()), + ("transport_result", render::()), + ("transport_receipt", render::()), + ("transport_envelope", render::()), + ]; + let dir = schemas_dir(); + if std::env::var_os(REGENERATE).is_some() { + std::fs::create_dir_all(&dir).unwrap(); + for (name, text) in &documents { + std::fs::write(dir.join(format!("{name}.json")), text).unwrap(); + } + } + for (name, text) in &documents { + let committed = std::fs::read_to_string(dir.join(format!("{name}.json"))) + .unwrap_or_else(|_| panic!("schemas/{name}.json is missing")); + assert_eq!(&committed, text, "schemas/{name}.json is not the type's"); + } + let mut on_disk: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + on_disk.sort(); + let mut expected: Vec = documents + .iter() + .map(|(name, _)| format!("{name}.json")) + .collect(); + expected.sort(); + assert_eq!( + on_disk, expected, + "schemas/ holds exactly the five documents" + ); +} + +#[test] +fn a_published_schema_refuses_an_unknown_field() { + let schema = serde_json::to_value(schema_for!(ProviderRequest)).unwrap(); + assert_eq!(schema["additionalProperties"], serde_json::json!(false)); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("max_output_tokens"))); +} diff --git a/crates/model-connector/tests/wire_golden_v3.rs b/crates/model-connector/tests/wire_golden_v3.rs new file mode 100644 index 0000000..5f91513 --- /dev/null +++ b/crates/model-connector/tests/wire_golden_v3.rs @@ -0,0 +1,214 @@ +//! Pins the bytes of the v3 wire so a drift is a failing test. The fixtures in +//! `fixtures/wire_v3` are produced by this file (see its README); they pin the +//! kit against itself and are not a second contract: the types in +//! `model_connector::contract` own the shape. + +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultnet_rs::{ + CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, +}; +use model_connector::*; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "golden-distinct-test-key"; +const CALLER: &str = "golden-caller"; +const REGENERATE: &str = "WIRE_V3_REGENERATE"; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire_v3") +} + +fn named(value: &T) -> Vec { + rmp_serde::to_vec_named(value).unwrap() +} + +/// The value serializes to the committed bytes and the committed bytes decode +/// back to it. With `WIRE_V3_REGENERATE` set, the committed bytes are rewritten +/// from the value first. +fn assert_wire( + name: &str, + value: &T, +) { + let path = fixtures().join(name); + if std::env::var_os(REGENERATE).is_some() { + std::fs::create_dir_all(fixtures()).unwrap(); + std::fs::write(&path, named(value)).unwrap(); + } + let golden = std::fs::read(&path).unwrap_or_else(|_| panic!("{name}: fixture is missing")); + assert_eq!(named(value), golden, "{name}: serialized bytes"); + let decoded: T = rmp_serde::from_slice(&golden).unwrap(); + assert_eq!(&decoded, value, "{name}: decoded value"); +} + +fn request() -> ProviderRequest { + let mut request = ProviderRequest::new( + "golden-request", + "golden-conversation", + "claude-opus-4-5", + "Return the typed result.", + 8_192, + ); + request.input = vec![ + InputItem::UserText { + text: "Projected state".to_string(), + }, + InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "evidence".to_string(), + }, + ]; + request.reasoning_effort = Some(ReasoningEffort::High); + request.cache_retention = Some(CacheRetention::Short); + request.tools = vec![ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = ToolChoice::Required; + request.parallel_tool_calls = true; + request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + request +} + +fn invocation() -> TransportInvocation { + TransportInvocation::new(CALLER, 1_800_000_000_000, [7; 32], request()).unwrap() +} + +fn receipt(outcome: TransportOutcome) -> TransportReceipt { + let invocation = invocation(); + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: CALLER.to_string(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + provider: "anthropic-messages".to_string(), + outcome, + } +} + +fn completed() -> TransportOutcome { + TransportOutcome::Completed { + provider_response_id: Some("msg-1".to_string()), + stop_reason: StopReason::ToolUse, + usage: Usage { + input_tokens: 120, + cache_read_tokens: 40, + cache_write_tokens: 8, + output_tokens: 33, + reasoning_tokens: Some(12), + }, + } +} + +#[test] +fn wire_golden_v3() { + let invocation = invocation(); + assert_wire("provider_request.mp", &invocation.request); + assert_wire("transport_invocation.mp", &invocation); + + let transported = TransportResult::transported( + &invocation, + vec![ + TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "thinking".to_string(), + }, + }, + TransportEvent { + sequence: 1, + payload: TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt(completed()), + ); + assert_eq!(transported.validate_against(&invocation), Ok(())); + assert_wire("transport_result_transported.mp", &transported); + + let refused = TransportResult::refused(&invocation, Refusal::OutputBound); + assert_wire("transport_result_refused.mp", &refused); + + let failed = receipt(TransportOutcome::Failed { + kind: FailureKind::RateLimited, + message: "provider asked to slow down".to_string(), + }); + assert_eq!(failed.validate_against(&invocation), Ok(())); + assert_wire("transport_receipt_failed.mp", &failed); + + let envelope = TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: CALLER.to_string(), + request_id: "golden-request".to_string(), + message_kind: EnvelopeKind::Invocation, + nonce: [0x80, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0xff], + ciphertext: (0..40_u8).map(|byte| byte.wrapping_mul(7)).collect(), + }; + assert_wire("transport_envelope.mp", &envelope); +} + +/// The committed carriage frame is a `cultnet.operation_request.v0` for the +/// connector, read here with CultNet itself and not the kit's own carriage code. +/// Its payload is the base64 of a sealed envelope that decrypts under the +/// fixed key to the golden invocation. +#[test] +fn the_golden_carriage_frame_decrypts_and_validates_under_the_fixed_key() { + let path = fixtures().join("carriage_invocation.frame"); + let key = TransportKey::from_connection_secret(KEY).unwrap(); + let invocation = invocation(); + if std::env::var_os(REGENERATE).is_some() { + let envelope = encrypt_invocation(&invocation, &key).unwrap(); + let message = CultNetMessage::OperationRequest { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: "messagepack-base64".to_string(), + payload: STANDARD.encode(named(&envelope)), + source_runtime_id: None, + target_runtime_id: None, + }; + std::fs::write( + &path, + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(), + ) + .unwrap(); + } + let frame = std::fs::read(&path).expect("carriage_invocation.frame is missing"); + let CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload, + .. + } = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).unwrap() + else { + panic!("the golden frame is a CultNet operation request"); + }; + assert_eq!(service_id, "gamecult.model.connector"); + assert_eq!(operation, "invoke"); + assert_eq!(payload_schema, "gamecult.model.transport_envelope.v3"); + assert_eq!(message_id, invocation.request_id()); + let envelope: TransportEnvelope = + rmp_serde::from_slice(&STANDARD.decode(payload).unwrap()).unwrap(); + assert_eq!(envelope.request_id, message_id); + let decrypted = decrypt_invocation(&envelope, &key).unwrap(); + assert_eq!(decrypted, invocation); + assert_eq!(decrypted.validate(1_799_999_999_000, 5_000), Ok(())); +} diff --git a/schemas/provider_request.json b/schemas/provider_request.json new file mode 100644 index 0000000..d4fb346 --- /dev/null +++ b/schemas/provider_request.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ProviderRequest", + "type": "object", + "properties": { + "cache_retention": { + "anyOf": [ + { + "$ref": "#/$defs/CacheRetention" + }, + { + "type": "null" + } + ] + }, + "conversation_id": { + "type": "string" + }, + "input": { + "type": "array", + "items": { + "$ref": "#/$defs/InputItem" + } + }, + "instructions": { + "type": "string" + }, + "max_output_tokens": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "output_schema_json": { + "type": [ + "string", + "null" + ] + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/$defs/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "tool_choice": { + "$ref": "#/$defs/ToolChoice" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolDefinition" + } + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "conversation_id", + "model", + "instructions", + "input", + "tools", + "tool_choice", + "parallel_tool_calls", + "max_output_tokens" + ], + "$defs": { + "CacheRetention": { + "type": "string", + "enum": [ + "short", + "long" + ] + }, + "InputItem": { + "oneOf": [ + { + "type": "object", + "properties": { + "UserText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "UserText" + ] + }, + { + "type": "object", + "properties": { + "AssistantText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "AssistantText" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + }, + { + "type": "object", + "properties": { + "ToolResult": { + "type": "object", + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "output" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolResult" + ] + } + ] + }, + "ReasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "max" + ] + }, + "ToolChoice": { + "type": "string", + "enum": [ + "auto", + "required" + ] + }, + "ToolDefinition": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters_json": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "description", + "parameters_json" + ] + } + } +} diff --git a/schemas/transport_envelope.json b/schemas/transport_envelope.json new file mode 100644 index 0000000..aee5469 --- /dev/null +++ b/schemas/transport_envelope.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportEnvelope", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "ciphertext": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + } + }, + "message_kind": { + "$ref": "#/$defs/EnvelopeKind" + }, + "nonce": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 12, + "minItems": 12 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "caller_runtime_id", + "request_id", + "message_kind", + "nonce", + "ciphertext" + ], + "$defs": { + "EnvelopeKind": { + "type": "string", + "enum": [ + "Invocation", + "Result" + ] + } + } +} diff --git a/schemas/transport_invocation.json b/schemas/transport_invocation.json new file mode 100644 index 0000000..9e899b1 --- /dev/null +++ b/schemas/transport_invocation.json @@ -0,0 +1,279 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportInvocation", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "expires_at_unix_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request": { + "$ref": "#/$defs/ProviderRequest" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "caller_runtime_id", + "expires_at_unix_ms", + "native_request_sha256", + "provider_request_sha256", + "request" + ], + "$defs": { + "CacheRetention": { + "type": "string", + "enum": [ + "short", + "long" + ] + }, + "InputItem": { + "oneOf": [ + { + "type": "object", + "properties": { + "UserText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "UserText" + ] + }, + { + "type": "object", + "properties": { + "AssistantText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "AssistantText" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + }, + { + "type": "object", + "properties": { + "ToolResult": { + "type": "object", + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "output" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolResult" + ] + } + ] + }, + "ProviderRequest": { + "type": "object", + "properties": { + "cache_retention": { + "anyOf": [ + { + "$ref": "#/$defs/CacheRetention" + }, + { + "type": "null" + } + ] + }, + "conversation_id": { + "type": "string" + }, + "input": { + "type": "array", + "items": { + "$ref": "#/$defs/InputItem" + } + }, + "instructions": { + "type": "string" + }, + "max_output_tokens": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "output_schema_json": { + "type": [ + "string", + "null" + ] + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/$defs/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "tool_choice": { + "$ref": "#/$defs/ToolChoice" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolDefinition" + } + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "conversation_id", + "model", + "instructions", + "input", + "tools", + "tool_choice", + "parallel_tool_calls", + "max_output_tokens" + ] + }, + "ReasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "max" + ] + }, + "ToolChoice": { + "type": "string", + "enum": [ + "auto", + "required" + ] + }, + "ToolDefinition": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters_json": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "description", + "parameters_json" + ] + } + } +} diff --git a/schemas/transport_receipt.json b/schemas/transport_receipt.json new file mode 100644 index 0000000..68916ed --- /dev/null +++ b/schemas/transport_receipt.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportReceipt", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "outcome": { + "$ref": "#/$defs/TransportOutcome" + }, + "provider": { + "type": "string" + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "native_request_sha256", + "provider_request_sha256", + "model", + "provider", + "outcome" + ], + "$defs": { + "FailureKind": { + "description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.", + "type": "string", + "enum": [ + "result_size", + "invalid_request", + "authentication", + "rate_limited", + "unavailable", + "timeout", + "provider" + ] + }, + "StopReason": { + "description": "Why a provider stopped producing output.", + "type": "string", + "enum": [ + "end_turn", + "tool_use", + "max_output_tokens", + "stop_sequence", + "refusal", + "context_window" + ] + }, + "TransportOutcome": { + "oneOf": [ + { + "type": "object", + "properties": { + "Completed": { + "type": "object", + "properties": { + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "stop_reason": { + "$ref": "#/$defs/StopReason" + }, + "usage": { + "$ref": "#/$defs/Usage" + } + }, + "additionalProperties": false, + "required": [ + "stop_reason", + "usage" + ] + } + }, + "additionalProperties": false, + "required": [ + "Completed" + ] + }, + { + "type": "object", + "properties": { + "Failed": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/FailureKind" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "Failed" + ] + } + ] + }, + "Usage": { + "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "type": "object", + "properties": { + "cache_read_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cache_write_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "input_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "output_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "reasoning_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "output_tokens" + ] + } + } +} diff --git a/schemas/transport_result.json b/schemas/transport_result.json new file mode 100644 index 0000000..ceb1e42 --- /dev/null +++ b/schemas/transport_result.json @@ -0,0 +1,342 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportResult", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/TransportDisposition" + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "disposition" + ], + "$defs": { + "FailureKind": { + "description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.", + "type": "string", + "enum": [ + "result_size", + "invalid_request", + "authentication", + "rate_limited", + "unavailable", + "timeout", + "provider" + ] + }, + "Refusal": { + "type": "string", + "enum": [ + "Expired", + "IdentitySubstitution", + "ProviderDigestSubstitution", + "ModelNotAllowed", + "OutputBound", + "PayloadBound", + "Capacity", + "InFlight", + "Indeterminate", + "ReplayConflict", + "Malformed" + ] + }, + "StopReason": { + "description": "Why a provider stopped producing output.", + "type": "string", + "enum": [ + "end_turn", + "tool_use", + "max_output_tokens", + "stop_sequence", + "refusal", + "context_window" + ] + }, + "TransportDisposition": { + "oneOf": [ + { + "type": "object", + "properties": { + "Refused": { + "$ref": "#/$defs/Refusal" + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + }, + { + "type": "object", + "properties": { + "Transported": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/TransportEvent" + } + }, + "receipt": { + "$ref": "#/$defs/TransportReceipt" + } + }, + "additionalProperties": false, + "required": [ + "events", + "receipt" + ] + } + }, + "additionalProperties": false, + "required": [ + "Transported" + ] + } + ] + }, + "TransportEvent": { + "type": "object", + "properties": { + "payload": { + "$ref": "#/$defs/TransportEventPayload" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "sequence", + "payload" + ] + }, + "TransportEventPayload": { + "oneOf": [ + { + "type": "object", + "properties": { + "TextDelta": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "TextDelta" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + } + ] + }, + "TransportOutcome": { + "oneOf": [ + { + "type": "object", + "properties": { + "Completed": { + "type": "object", + "properties": { + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "stop_reason": { + "$ref": "#/$defs/StopReason" + }, + "usage": { + "$ref": "#/$defs/Usage" + } + }, + "additionalProperties": false, + "required": [ + "stop_reason", + "usage" + ] + } + }, + "additionalProperties": false, + "required": [ + "Completed" + ] + }, + { + "type": "object", + "properties": { + "Failed": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/FailureKind" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "Failed" + ] + } + ] + }, + "TransportReceipt": { + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "outcome": { + "$ref": "#/$defs/TransportOutcome" + }, + "provider": { + "type": "string" + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "native_request_sha256", + "provider_request_sha256", + "model", + "provider", + "outcome" + ] + }, + "Usage": { + "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "type": "object", + "properties": { + "cache_read_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cache_write_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "input_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "output_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "reasoning_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "output_tokens" + ] + } + } +} From 618475b1596637b3df2f232a0ac1eb4075b5c4f8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:21:52 +0100 Subject: [PATCH 07/13] connector-contract-v3: the server-side carriage functions exist with the daemon feature c23d913 does not build with default features under clippy -D warnings: the result encoder and invocation decoder are server-side only. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 0ece953..e77b212 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -79,6 +79,7 @@ pub(crate) fn encode_invocation_frame( /// The CultNet frame a result travels in: a `cultnet.operation_response.v0` /// with status `ok`, otherwise shaped like the invocation's. +#[cfg(any(feature = "daemon", test))] pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result, ServiceError> { carriage_bytes(&CultNetMessage::OperationResponse { message_id: envelope.request_id.clone(), @@ -124,6 +125,7 @@ fn read_carriage(bytes: &[u8]) -> Result { .map_err(|_| ServiceError::Carriage) } +#[cfg(any(feature = "daemon", test))] pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result { match read_carriage(bytes)? { CultNetMessage::OperationRequest { From face15ec16bd87dfe2008a37590a0acdcc672e84 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:22:45 +0100 Subject: [PATCH 08/13] connector-contract-v3: the payload bound a frame allows is the largest payload that fits The carriage grows in base64 groups of three bytes, so the frame is not injective in the payload: payload_bytes_for_frame returns the largest payload whose frame fits, and the tests say so. The slack of the not-wildly-above check covers the real nonce being shorter than the worst one. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index e77b212..db7ce33 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -693,22 +693,26 @@ mod tests { // within the base64 expansion of the real one. if id.len() == MAX_IDENTITY_BYTES { let real = encode_result_frame(&sealed).unwrap().len(); - assert!(worst <= real + 8, "{worst} {real}"); + assert!(worst <= real + 32, "{worst} {real}"); } } } } #[test] - fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { + fn the_payload_bound_a_frame_allows_is_the_largest_payload_that_fits() { + // The frame grows in base64 groups of three bytes, so several payloads + // share a frame: the inverse is the largest of them. assert_eq!( payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None ); - assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); for bytes in boundary_sizes().into_iter().chain([2_000_000]) { let frame = frame_bytes_for_payload(bytes); - assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); + let allowed = payload_bytes_for_frame(frame).unwrap(); + assert!(allowed >= bytes && allowed - bytes < 3, "{bytes} {allowed}"); + assert_eq!(frame_bytes_for_payload(allowed), frame, "{bytes}"); + assert!(frame_bytes_for_payload(allowed + 1) > frame, "{bytes}"); assert!( payload_bytes_for_frame(frame - 1).unwrap() < bytes, "{bytes}" From b6ad75c6106962bf2444b59f8df569eca2458246 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:23:21 +0100 Subject: [PATCH 09/13] connector-contract-v3: the smallest frame has no smaller payload bound Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index db7ce33..f0b423a 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -714,7 +714,7 @@ mod tests { assert_eq!(frame_bytes_for_payload(allowed), frame, "{bytes}"); assert!(frame_bytes_for_payload(allowed + 1) > frame, "{bytes}"); assert!( - payload_bytes_for_frame(frame - 1).unwrap() < bytes, + payload_bytes_for_frame(frame - 1).is_none_or(|smaller| smaller < bytes), "{bytes}" ); } From 2101602b8fa847b0038c36c07ebaf19ed2932a06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:24:21 +0100 Subject: [PATCH 10/13] connector-contract-v3: bracket the over-long-id refusal by the carriage frame Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 75ac66f..157ab20 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -1027,12 +1027,12 @@ mod tests { &security(KEY), ) .unwrap(); - // The sealed size varies with the random nonce and ciphertext bytes - // (a byte of 128 or more takes two), so bracket it: the ciphertext - // plus the id is a floor, twice the ciphertext plus the id and the - // envelope's other fields is a ceiling. + // The frame carries the id twice (the message id and the envelope's + // request id) and the base64 of the envelope, so bracket it: the + // ciphertext plus the id is a floor, twice the ciphertext and both + // ids plus the envelope's other fields is a ceiling. let floor = sealed.ciphertext.len() + id.len(); - let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512; + let ceiling = 2 * (sealed.ciphertext.len() + 2 * id.len()) + 512; let store = TestStore::new(); let mut service = open_service(&store, 1); assert!(matches!( From aed07a4b719a3c1897359431f3b9f1980ad38c0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 11:38:11 +0100 Subject: [PATCH 11/13] connector-contract-v3: drop the unreachable short-string arms of the str header width Mutation survivors on 2101602: the worst-case payload is the base64 of an envelope holding two 256-byte ids, so it never fits a one- or two-byte str header. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index f0b423a..9b9cd45 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -226,14 +226,11 @@ fn bin_header_bytes(len: usize) -> usize { } } -/// MessagePack `str` header width for a string of `len` bytes. +/// MessagePack `str` header width for the base64 payload. The payload always +/// exceeds 255 bytes: the envelope it encodes holds two ids of at least +/// `MAX_IDENTITY_BYTES` in the worst case this bound measures. fn str_header_bytes(len: usize) -> usize { - match len { - 0..=31 => 1, - 32..=255 => 2, - 256..=65_535 => 3, - _ => 5, - } + if len <= 65_535 { 3 } else { 5 } } /// The largest payload bound whose worst-case frame fits `max_frame_bytes`: From 03bc88ae9741535ab1d252c1e083264d85bdccf1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 12:01:42 +0100 Subject: [PATCH 12/13] connector-contract-v3: pin the carriage's service id, operation and message shapes against the committed frame and the wire The golden carriage frame is read and re-encoded by the kit's own carriage functions, and both directions' frames are asserted field by field on the CultNet wire, so a renamed service id or operation fails. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 9b9cd45..7ea243d 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -911,4 +911,64 @@ mod tests { Err(ServiceError::Encoding) ); } + + fn golden_frame() -> Vec { + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/wire_v3/carriage_invocation.frame"), + ) + .expect("carriage_invocation.frame is committed") + } + + #[test] + fn the_committed_golden_frame_is_what_the_kit_reads_and_writes() { + let frame = golden_frame(); + let envelope = decode_invocation_frame(&frame).unwrap(); + assert_eq!(encode_invocation_frame(&envelope).unwrap(), frame); + } + + fn top_level(frame: &[u8]) -> std::collections::BTreeMap { + let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &frame[..]).unwrap() else { + panic!("a CultNet frame is a map"); + }; + entries + .into_iter() + .map(|(key, value)| (key.as_str().unwrap().to_string(), value)) + .collect() + } + + #[test] + fn the_frames_name_the_connector_service_and_its_one_operation_on_the_wire() { + let envelope = sealed_for_carriage(); + let text = |map: &std::collections::BTreeMap, key: &str| { + map[key].as_str().unwrap().to_string() + }; + let request = top_level(&encode_invocation_frame(&envelope).unwrap()); + assert_eq!( + text(&request, "schemaVersion"), + "cultnet.operation_request.v0" + ); + assert_eq!(text(&request, "serviceId"), "gamecult.model.connector"); + assert_eq!(text(&request, "operation"), "invoke"); + assert_eq!( + text(&request, "payloadSchema"), + "gamecult.model.transport_envelope.v3" + ); + assert_eq!(text(&request, "payloadEncoding"), "messagepack-base64"); + assert_eq!(text(&request, "messageId"), envelope.request_id); + let response = top_level(&encode_result_frame(&envelope).unwrap()); + assert_eq!( + text(&response, "schemaVersion"), + "cultnet.operation_response.v0" + ); + assert_eq!(text(&response, "serviceId"), "gamecult.model.connector"); + assert_eq!(text(&response, "operation"), "invoke"); + assert_eq!(text(&response, "status"), "ok"); + assert_eq!( + text(&response, "payloadSchema"), + "gamecult.model.transport_envelope.v3" + ); + assert_eq!(text(&response, "payloadEncoding"), "messagepack-base64"); + assert_eq!(text(&response, "messageId"), envelope.request_id); + } } From a0a6ccbf0d0fc00b4f3fb5e8ee8e31f50c1d51c4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 12:03:23 +0100 Subject: [PATCH 13/13] connector-contract-v3: the decoder refuses every dialect the wire does not name; the ciphertext is published as binary wire_decode accepts a document only when its MessagePack value equals the named form wire_encode writes for the decoded value (key order, integer width and an absent optional field aside), so a positional envelope or invocation, an integer-array ciphertext, an enum sent as its index and bytes after the document are refused Encoding through the real carriage. The schema describes the ciphertext as binary, and a test pins schema and wire agreeing. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/Cargo.toml | 4 +- crates/model-connector/src/contract.rs | 44 ++++- crates/model-connector/src/envelope.rs | 166 +++++++++++++++++- .../tests/published_schemas.rs | 34 ++++ schemas/transport_envelope.json | 10 +- 5 files changed, 245 insertions(+), 13 deletions(-) diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index 8bd884a..c54547b 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -18,6 +18,7 @@ cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056 cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs", optional = true } rand_core = { version = "0.6", features = ["getrandom"] } rmp-serde = "1" +rmpv = "1" schemars = "1" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" @@ -25,6 +26,3 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" zeroize = "1" - -[dev-dependencies] -rmpv = "1" diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 5796d19..d60fab5 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -462,8 +462,48 @@ pub(crate) fn wire_encode(document: &T) -> Result rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding) } -pub(crate) fn wire_decode(bytes: &[u8]) -> Result { - rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding) +/// Decodes a document and refuses every dialect the wire does not name. The +/// typed decoder alone also reads a positional array for a struct, an integer +/// array where `bin` is declared and an enum as its index; so the bytes must +/// equal what [`wire_encode`] writes for the decoded value, compared as +/// MessagePack values: key order, integer width and an absent optional field +/// do not matter, array against map, array against `bin` and index against name do. +pub(crate) fn wire_decode( + bytes: &[u8], +) -> Result { + let document: T = rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding)?; + if named_form(bytes)? != named_form(&wire_encode(&document)?)? { + return Err(ContractError::Encoding); + } + Ok(document) +} + +fn named_form(bytes: &[u8]) -> Result { + let mut reader = bytes; + let value = rmpv::decode::read_value(&mut reader).map_err(|_| ContractError::Encoding)?; + if !reader.is_empty() { + return Err(ContractError::Encoding); + } + Ok(order_free(value)) +} + +/// A map compared as a set of its present entries: sorted by key, nil entries dropped. +fn order_free(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => { + let mut kept: Vec<(rmpv::Value, rmpv::Value)> = entries + .into_iter() + .filter(|(_, entry)| !entry.is_nil()) + .map(|(key, entry)| (key, order_free(entry))) + .collect(); + kept.sort_by_key(|(key, _)| key.to_string()); + rmpv::Value::Map(kept) + } + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(order_free).collect()) + } + other => other, + } } pub fn canonical_provider_request_bytes( diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 7ea243d..0f87546 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -33,10 +33,19 @@ pub struct TransportEnvelope { pub message_kind: EnvelopeKind, pub nonce: [u8; 12], #[serde(with = "serde_bytes")] - #[schemars(with = "Vec")] + #[schemars(schema_with = "binary_schema")] pub ciphertext: Vec, } +/// The ciphertext is MessagePack `bin` on the wire, never an array of integers. +fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "format": "binary", + "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers" + }) +} + /// The CultNet service every frame of this connector addresses. pub const CONNECTOR_SERVICE_ID: &str = "gamecult.model.connector"; /// The one operation of that service: run one sealed invocation. @@ -971,4 +980,159 @@ mod tests { assert_eq!(text(&response, "payloadEncoding"), "messagepack-base64"); assert_eq!(text(&response, "messageId"), envelope.request_id); } + + fn value_of(bytes: &[u8]) -> rmpv::Value { + rmpv::decode::read_value(&mut &bytes[..]).unwrap() + } + + fn bytes_of(value: &rmpv::Value) -> Vec { + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, value).unwrap(); + out + } + + fn with_entry(value: rmpv::Value, name: &str, entry: rmpv::Value) -> rmpv::Value { + let rmpv::Value::Map(mut entries) = value else { + panic!("a named document is a map"); + }; + let slot = entries + .iter_mut() + .find(|(key, _)| key.as_str() == Some(name)) + .unwrap(); + slot.1 = entry; + rmpv::Value::Map(entries) + } + + fn reverse_maps(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => rmpv::Value::Map( + entries + .into_iter() + .rev() + .map(|(key, entry)| (key, reverse_maps(entry))) + .collect(), + ), + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(reverse_maps).collect()) + } + other => other, + } + } + + fn without_nils(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => rmpv::Value::Map( + entries + .into_iter() + .filter(|(_, entry)| !entry.is_nil()) + .map(|(key, entry)| (key, without_nils(entry))) + .collect(), + ), + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(without_nils).collect()) + } + other => other, + } + } + + fn request_frame_carrying(payload: &[u8]) -> Vec { + request_message(|message| { + if let CultNetMessage::OperationRequest { payload: sent, .. } = message { + *sent = STANDARD.encode(payload); + } + }) + } + + fn response_frame_carrying(payload: &[u8]) -> Vec { + response_message(|message| { + if let CultNetMessage::OperationResponse { payload: sent, .. } = message { + *sent = STANDARD.encode(payload); + } + }) + } + + #[test] + fn the_carriage_refuses_every_dialect_the_wire_does_not_name() { + let envelope = sealed_for_carriage(); + let named = value_of(&wire_encode(&envelope).unwrap()); + let integer_ciphertext = rmpv::Value::Array( + envelope + .ciphertext + .iter() + .map(|byte| rmpv::Value::from(*byte)) + .collect(), + ); + let dialects = [ + ("positional envelope", rmp_serde::to_vec(&envelope).unwrap()), + ( + "integer-array ciphertext", + bytes_of(&with_entry(named.clone(), "ciphertext", integer_ciphertext)), + ), + ( + "enum as its index", + bytes_of(&with_entry(named, "message_kind", 0.into())), + ), + ( + "bytes after the document", + [wire_encode(&envelope).unwrap(), vec![0xc0]].concat(), + ), + ]; + for (dialect, payload) in &dialects { + assert_eq!( + decode_invocation_frame(&request_frame_carrying(payload)), + Err(ServiceError::Encoding), + "{dialect} on a request" + ); + assert_eq!( + decode_result_frame(&response_frame_carrying(payload)), + Err(ServiceError::Encoding), + "{dialect} on a response" + ); + } + // The named form itself is read, so the refusals above are the dialect's. + let named_bytes = wire_encode(&envelope).unwrap(); + assert_eq!( + decode_invocation_frame(&request_frame_carrying(&named_bytes)), + Ok(envelope) + ); + } + + #[test] + fn a_sealed_positional_invocation_is_refused_after_decryption() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let sealed = encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &rmp_serde::to_vec(&invocation).unwrap(), + &key, + ) + .unwrap(); + let arrived = decode_invocation_frame(&encode_invocation_frame(&sealed).unwrap()).unwrap(); + assert_eq!( + decrypt_invocation(&arrived, &key), + Err(ServiceError::Encoding) + ); + let sealed = encrypt_invocation(&invocation, &key).unwrap(); + assert_eq!(decrypt_invocation(&sealed, &key), Ok(invocation)); + } + + #[test] + fn named_maps_in_any_key_order_and_without_absent_options_are_the_named_form() { + let envelope = sealed_for_carriage(); + let reversed = bytes_of(&reverse_maps(value_of(&wire_encode(&envelope).unwrap()))); + assert_ne!(reversed, wire_encode(&envelope).unwrap()); + assert_eq!(wire_decode::(&reversed), Ok(envelope)); + let invocation = invocation(); + let named = value_of(&wire_encode(&invocation).unwrap()); + for edited in [reverse_maps(named.clone()), without_nils(named)] { + let bytes = bytes_of(&edited); + assert_ne!(bytes, wire_encode(&invocation).unwrap()); + assert_eq!( + wire_decode::(&bytes), + Ok(invocation.clone()) + ); + } + } } diff --git a/crates/model-connector/tests/published_schemas.rs b/crates/model-connector/tests/published_schemas.rs index 1dabe18..4634280 100644 --- a/crates/model-connector/tests/published_schemas.rs +++ b/crates/model-connector/tests/published_schemas.rs @@ -64,3 +64,37 @@ fn a_published_schema_refuses_an_unknown_field() { let required = schema["required"].as_array().unwrap(); assert!(required.contains(&serde_json::json!("max_output_tokens"))); } + +/// The schema and the wire agree on the ciphertext: the schema says binary, not +/// an array of integers, and the encoder writes MessagePack `bin`. +#[test] +fn the_published_ciphertext_is_what_the_wire_sends() { + let schema = serde_json::to_value(schema_for!(TransportEnvelope)).unwrap(); + let ciphertext = &schema["properties"]["ciphertext"]; + assert_eq!(ciphertext["type"], "string"); + assert_eq!(ciphertext["format"], "binary"); + assert!(ciphertext.get("items").is_none()); + let published = std::fs::read_to_string(schemas_dir().join("transport_envelope.json")).unwrap(); + let published: serde_json::Value = serde_json::from_str(&published).unwrap(); + assert_eq!(&published["properties"]["ciphertext"], ciphertext); + + let envelope = TransportEnvelope { + schema_id: "gamecult.model.transport_envelope.v3".to_string(), + caller_runtime_id: "caller".to_string(), + request_id: "request".to_string(), + message_kind: EnvelopeKind::Invocation, + nonce: [1; 12], + ciphertext: vec![9; 40], + }; + let sent = + rmpv::decode::read_value(&mut rmp_serde::to_vec_named(&envelope).unwrap().as_slice()) + .unwrap(); + let rmpv::Value::Map(entries) = sent else { + panic!("the envelope is a named map"); + }; + let (_, wire) = entries + .iter() + .find(|(key, _)| key.as_str() == Some("ciphertext")) + .unwrap(); + assert!(matches!(wire, rmpv::Value::Binary(bytes) if bytes.len() == 40)); +} diff --git a/schemas/transport_envelope.json b/schemas/transport_envelope.json index aee5469..1983423 100644 --- a/schemas/transport_envelope.json +++ b/schemas/transport_envelope.json @@ -7,13 +7,9 @@ "type": "string" }, "ciphertext": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "maximum": 255, - "minimum": 0 - } + "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers", + "type": "string", + "format": "binary" }, "message_kind": { "$ref": "#/$defs/EnvelopeKind"