Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
a0a6ccbf0d 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 <noreply@anthropic.com>
2026-10-28 12:03:57 +01:00
Meta Vens
03bc88ae97 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 <noreply@anthropic.com>
2026-10-28 12:02:32 +01:00
Meta Vens
aed07a4b71 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 <noreply@anthropic.com>
2026-10-28 11:38:11 +01:00
Meta Vens
2101602b8f connector-contract-v3: bracket the over-long-id refusal by the carriage frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:24:21 +01:00
Meta Vens
b6ad75c610 connector-contract-v3: the smallest frame has no smaller payload bound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:21 +01:00
Meta Vens
face15ec16 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 <noreply@anthropic.com>
2026-10-28 11:22:45 +01:00
Meta Vens
618475b159 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 <noreply@anthropic.com>
2026-10-28 11:21:52 +01:00
Meta Vens
c23d91391d 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 <noreply@anthropic.com>
2026-10-28 11:21:11 +01:00
Meta Vens
5894632829 connector-contract-v3: the replay-seed test handles the Result of add_generic_backing_store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:18:02 +01:00
Meta Vens
33f092ea7f 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 <noreply@anthropic.com>
2026-10-28 11:16:50 +01:00
Meta Vens
e669d2b867 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 <noreply@anthropic.com>
2026-10-28 11:13:28 +01:00
Meta Vens
5a4d8bd477 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 <noreply@anthropic.com>
2026-10-28 11:11:53 +01:00
Meta Vens
685f449ddb 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 <noreply@anthropic.com>
2026-10-28 11:04:44 +01:00
49 changed files with 3127 additions and 767 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
schemas/*.json text eol=lf
crates/model-connector/tests/fixtures/wire_v3/* -text

431
Cargo.lock generated
View file

@ -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,15 @@ name = "model-connector"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64",
"cultcache-rs",
"rand_core",
"cultnet-rs",
"rand_core 0.6.4",
"rmp-serde",
"rmpv",
"schemars",
"serde",
"serde_bytes",
"serde_json",
"sha2",
"thiserror",
@ -360,6 +514,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 +541,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 +563,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 +581,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 +600,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 +641,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 +659,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 +698,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 +795,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 +819,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 +847,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 +988,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 +1123,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 +1141,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 +1214,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"

View file

@ -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

View file

@ -13,10 +13,15 @@ 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"
rmpv = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
sha2 = "0.10"
thiserror = "2"

View file

@ -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,16 +147,16 @@ 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::Policy),
&TransportResult::refused(&expected, Refusal::ModelNotAllowed),
&security,
)
.unwrap();
write_transport_frame(
&mut stream,
&rmp_serde::to_vec(&response).unwrap(),
&encode_result_frame(&response).unwrap(),
64 * 1024,
)
.unwrap();
@ -175,7 +171,7 @@ mod fixture_tests {
.unwrap();
assert_eq!(
client.execute(&invocation).unwrap().disposition,
TransportDisposition::Refused(Refusal::Policy)
TransportDisposition::Refused(Refusal::ModelNotAllowed)
);
server.join().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"
);
}
}

View file

@ -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,
@ -39,17 +42,13 @@ pub struct ProviderRequest {
pub model: String,
pub instructions: String,
pub input: Vec<InputItem>,
pub reasoning_effort: Option<String>,
pub reasoning_summary: Option<String>,
pub service_tier: Option<String>,
pub output_format_name: Option<String>,
pub previous_response_id: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
pub cache_retention: Option<CacheRetention>,
pub tools: Vec<ToolDefinition>,
pub tool_choice: ToolChoice,
pub parallel_tool_calls: bool,
pub output_schema_json: Option<String>,
pub max_output_tokens: Option<u32>,
pub prompt_cache_key: Option<String>,
pub max_output_tokens: u32,
}
impl ProviderRequest {
@ -58,6 +57,7 @@ impl ProviderRequest {
conversation_id: impl Into<String>,
model: impl Into<String>,
instructions: impl Into<String>,
max_output_tokens: u32,
) -> Self {
Self {
schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(),
@ -67,16 +67,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 +84,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 {
@ -160,14 +130,70 @@ 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, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
Low,
Medium,
High,
Max,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CacheRetention {
Short,
Long,
}
/// Why a provider stopped producing output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[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, JsonSchema)]
#[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, JsonSchema)]
#[serde(deny_unknown_fields)]
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<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub enum InputItem {
UserText {
text: String,
@ -186,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,
@ -246,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,
@ -254,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 {
@ -312,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,
@ -324,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,
@ -347,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,
@ -355,7 +388,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 +409,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 {
@ -398,27 +427,28 @@ 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<String>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
reasoning_output_tokens: Option<u64>,
cached_input_tokens: Option<u64>,
stop_reason: StopReason,
usage: Usage,
},
Failed {
failure_kind: String,
kind: FailureKind,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Refusal {
Expired,
IdentitySubstitution,
ProviderDigestSubstitution,
Policy,
ModelNotAllowed,
OutputBound,
PayloadBound,
Capacity,
InFlight,
Indeterminate,
@ -426,11 +456,61 @@ 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<T: Serialize + ?Sized>(document: &T) -> Result<Vec<u8>, ContractError> {
rmp_serde::to_vec_named(document).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<T: DeserializeOwned + Serialize>(
bytes: &[u8],
) -> Result<T, ContractError> {
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<rmpv::Value, ContractError> {
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(
request: &ProviderRequest,
) -> Result<Vec<u8>, 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> {
@ -444,7 +524,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> {
@ -508,7 +588,7 @@ pub enum ContractError {
Expiry,
#[error("transport event sequence is not contiguous")]
EventSequence,
#[error("canonical MessagePack encoding failed")]
#[error("wire encoding failed")]
Encoding,
}
@ -537,14 +617,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),
@ -584,8 +664,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(),
@ -638,4 +718,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<T: Serialize>(document: &T) -> Vec<u8> {
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::<ProviderRequest>(&wire_encode(&invocation.request).unwrap()).is_ok());
assert!(wire_decode::<ProviderRequest>(&with_extra_key(&invocation.request)).is_err());
assert!(wire_decode::<TransportInvocation>(&with_extra_key(&invocation)).is_err());
assert!(wire_decode::<TransportResult>(&with_extra_key(&result)).is_err());
assert!(wire_decode::<TransportReceipt>(&with_extra_key(&**sent)).is_err());
assert!(
wire_decode::<crate::envelope::TransportEnvelope>(&wire_encode(&envelope).unwrap())
.is_ok()
);
assert!(
wire_decode::<crate::envelope::TransportEnvelope>(&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)
);
}
}
}

View file

@ -2,83 +2,264 @@ 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(schema_with = "binary_schema")]
pub ciphertext: Vec<u8>,
}
/// 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.
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<Vec<u8>, ServiceError> {
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)
.map_err(|_| ServiceError::Carriage)
}
fn seal_payload(envelope: &TransportEnvelope) -> Result<String, ServiceError> {
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<Vec<u8>, 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.
#[cfg(any(feature = "daemon", test))]
pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result<Vec<u8>, 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<TransportEnvelope, ServiceError> {
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<CultNetMessage, ServiceError> {
decode_cultnet_message_from_slice(bytes, CultNetWireContract::CultNetSchemaV0)
.map_err(|_| ServiceError::Carriage)
}
#[cfg(any(feature = "daemon", test))]
pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result<TransportEnvelope, ServiceError> {
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<TransportEnvelope, ServiceError> {
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 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 {
if len <= 65_535 { 3 } else { 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<usize> {
// 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 +283,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 +296,7 @@ pub fn encrypt_result(
result: &TransportResult,
security: &TransportKey,
) -> Result<TransportEnvelope, ServiceError> {
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 +313,7 @@ pub fn decrypt_result(
) -> Result<TransportResult, ServiceError> {
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 +422,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<Vec<u8>, 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 +473,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 +557,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 +585,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 +636,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<u8> {
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<usize> {
let mut sizes: Vec<usize> = (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,35 +688,451 @@ 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 + 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 [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}");
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,
payload_bytes_for_frame(frame - 1).is_none_or(|smaller| smaller < bytes),
"{bytes}"
);
}
}
#[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<u8> {
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<u8> {
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<u8> {
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<u8> {
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(&not_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(&not_an_envelope),
Err(ServiceError::Encoding)
);
}
fn golden_frame() -> Vec<u8> {
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<String, rmpv::Value> {
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<String, rmpv::Value>, 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);
}
fn value_of(bytes: &[u8]) -> rmpv::Value {
rmpv::decode::read_value(&mut &bytes[..]).unwrap()
}
fn bytes_of(value: &rmpv::Value) -> Vec<u8> {
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<u8> {
request_message(|message| {
if let CultNetMessage::OperationRequest { payload: sent, .. } = message {
*sent = STANDARD.encode(payload);
}
})
}
fn response_frame_carrying(payload: &[u8]) -> Vec<u8> {
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::<TransportEnvelope>(&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::<TransportInvocation>(&bytes),
Ok(invocation.clone())
);
}
}
}

View file

@ -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),
},
},
}
}

View file

@ -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<Vec<u8>, 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,
@ -328,8 +327,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};
@ -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()
@ -906,7 +905,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 +948,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 {
@ -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.
@ -984,9 +983,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(),
}
)
@ -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)
);
}
}

View file

@ -7,13 +7,13 @@ 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,
};
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,
@ -159,9 +159,12 @@ impl TransportService {
replay_store
.register_entry_type::<ReplayRecord>()
.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)?;
@ -228,18 +231,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 {
@ -312,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.
@ -423,13 +424,11 @@ impl TransportService {
}
fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result<bool, ServiceError> {
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 transport named on the stand-in failure, fixed so its size is bounded.
const STAND_IN_TRANSPORT: &str = "model-connector";
/// The provider named on the stand-in failure, fixed so its size is bounded.
const STAND_IN_PROVIDER: &str = "model-connector";
/// The failed result that stands in for one whose sealed frame cannot be
/// carried: the same shape a provider failure takes, with nothing a provider
@ -454,9 +453,9 @@ pub(crate) fn result_size_failure(
native_request_sha256,
provider_request_sha256,
model: model.to_string(),
transport: STAND_IN_TRANSPORT.to_string(),
provider: STAND_IN_PROVIDER.to_string(),
outcome: TransportOutcome::Failed {
failure_kind: "result_size".to_string(),
kind: FailureKind::ResultSize,
message: "provider result exceeded its byte bound".to_string(),
},
}),
@ -760,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()
@ -775,25 +774,26 @@ mod tests {
}
#[test]
fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() {
fn an_unlisted_model_is_refused_model_not_allowed() {
let store = TestStore::new();
let mut service = open_service(&store, 8);
let (admission, invocation) = run(&mut service, "unlisted-model", |request| {
request.model = "other-model".to_string();
});
assert_eq!(refusal_of(admission, &invocation), Refusal::Policy);
assert_eq!(refusal_of(admission, &invocation), Refusal::ModelNotAllowed);
let (admission, _) = run(&mut service, "listed-model", |_| {});
assert!(matches!(admission, TransportAdmission::Execute(_)));
}
#[test]
fn output_tokens_over_the_caller_cap_are_refused_output_bound_inclusively() {
let store = TestStore::new();
let mut service = open_service(&store, 8);
let (admission, invocation) = run(&mut service, "over-cap", |request| {
request.max_output_tokens = Some(32_769);
request.max_output_tokens = 32_769;
});
assert_eq!(refusal_of(admission, &invocation), Refusal::Policy);
for (id, tokens) in [
("at-cap", Some(32_768)),
("under-cap", Some(100)),
("unset", None),
] {
assert_eq!(refusal_of(admission, &invocation), Refusal::OutputBound);
for (id, tokens) in [("at-cap", 32_768), ("under-cap", 100)] {
let (admission, _) = run(&mut service, id, |request| {
request.max_output_tokens = tokens;
});
@ -824,7 +824,7 @@ mod tests {
};
assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_))));
let refused = admit(size - 1).unwrap();
assert_eq!(refusal_of(refused, &invocation), Refusal::Policy);
assert_eq!(refusal_of(refused, &invocation), Refusal::PayloadBound);
}
#[test]
@ -869,7 +869,9 @@ mod tests {
edit(&mut record);
let mut cache = CultCache::new();
cache.register_entry_type::<ReplayRecord>().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();
}
@ -1003,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)
@ -1024,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!(
@ -1049,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));
}
@ -1074,7 +1077,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 {
@ -1094,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,
@ -1109,7 +1112,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
@ -1168,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);
@ -1188,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(
@ -1247,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:?}");
}
}
}

View file

@ -1,2 +0,0 @@
*.mp binary
*.bin binary

View file

@ -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 <ModelConnector>/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 <name> <hex>` 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.

View file

@ -1,6 +0,0 @@
栙$gamecult.codex.transport_envelope.v2璸arity-caller﹔equest-1狪nvocation溙<6E>塘h虄虒抬x泰Y探<1B>環谭听题虠虆3陶 糖烃虘誊h抬台涕[酞藤!A屉虊甜铁疼;屉铁| 酞躺`!铁铁虤抬虗
虂k剔'vCZ锑號虒虉 8J抬汤d蹋虠N炭F炭zm6<6D><36>
眺添甜\\NMq號套虠 贴1替苔摊惕 4*{趟胎瘫
讨苔e糖台虖
贪虇i贪G*S虂坛碳桃胎坍誊!N汤萄虪虃太虣7虂糖贪天虄汤态c苔趟 桃滩1k提~h踢3$腾虪K踢贪贴誊4{逃踢碳泰U.)烫汰踏r虪听天唐^bbI虉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屉

View file

@ -1,7 +0,0 @@
–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì<E280A1>GÌ©ÜCÌôÌË Ì<>ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘
Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ
̱̘ÌË
[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì<C2A2>hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì<>kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô
ÌÁÌò̸%Ì»Ì<C2BB>@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x
r]F8Ì<38>ÌÛÌ‹Ì÷Ì<C3B7>5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ<C3A6>zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ<C3B6>ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f
Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[

View file

@ -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 <name> <hex>` 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<T: Serialize>(value: &T) -> Vec<u8> {
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()),
);
}
}
}

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¨Capacity

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused§Expired

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused´IdentitySubstitution

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¨InFlight

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused­Indeterminate

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused©Malformed

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¦Policy

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§RefusedºProviderDigestSubstitution

View file

@ -1 +0,0 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused®ReplayConflict

View file

@ -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.

View file

@ -0,0 +1 @@
<EFBFBD>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

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
„©schema_idÙ"gamecult.model.transport_result.v3ªrequest_id®golden-request±caller_runtime_id­golden-caller«disposition<6F>§Refused«OutputBound

View file

@ -0,0 +1,100 @@
//! `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<T: JsonSchema>() -> 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::<ProviderRequest>()),
("transport_invocation", render::<TransportInvocation>()),
("transport_result", render::<TransportResult>()),
("transport_receipt", render::<TransportReceipt>()),
("transport_envelope", render::<TransportEnvelope>()),
];
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<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
on_disk.sort();
let mut expected: Vec<String> = 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")));
}
/// 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));
}

View file

@ -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<T: Serialize>(value: &T) -> Vec<u8> {
rmp_serde::to_vec(value).unwrap()
}
/// The value serializes to the golden bytes and the golden bytes decode back to it.
fn assert_wire<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(
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());
}

View file

@ -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<T: Serialize>(value: &T) -> Vec<u8> {
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<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(
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(()));
}

View file

@ -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"
]
}
}
}

View file

@ -0,0 +1,53 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "TransportEnvelope",
"type": "object",
"properties": {
"caller_runtime_id": {
"type": "string"
},
"ciphertext": {
"description": "MessagePack bin: the raw ciphertext bytes, never an array of integers",
"type": "string",
"format": "binary"
},
"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"
]
}
}
}

View file

@ -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"
]
}
}
}

View file

@ -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"
]
}
}
}

View file

@ -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"
]
}
}
}