Merge feat/sf-contract-01: the fly-session-types crate, so the session slice builds on the shared contract types

This commit is contained in:
acamilo 2026-09-22 12:29:45 +00:00
commit 825b584320
41 changed files with 20716 additions and 4 deletions

View file

@ -420,15 +420,23 @@ dependencies = [
name = "fly-session" name = "fly-session"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"fly-session-types",
"flybus", "flybus",
"ryu-js",
"serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
] ]
[[package]]
name = "fly-session-types"
version = "0.1.1"
dependencies = [
"flybus",
"ryu-js",
"serde_json",
"sha2",
]
[[package]] [[package]]
name = "flybrain-core" name = "flybrain-core"
version = "0.1.1" version = "0.1.1"

View file

@ -1,10 +1,11 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = [
"crates/fly-session",
"crates/fly-session-types",
"crates/flybrain-core", "crates/flybrain-core",
"crates/flybrain-gb", "crates/flybrain-gb",
"crates/flybus", "crates/flybus",
"crates/fly-session",
"crates/flysim", "crates/flysim",
] ]

View file

@ -0,0 +1,16 @@
[package]
name = "fly-session-types"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "Session domain scalars, closed enums, method payloads, canonical JSON digests and the step trace format (session-framework CONTRACT-01)."
[dependencies]
# The bus owns the Id/U64/Digest encodings, strict JSON and ArtifactRef; this crate reuses
# them rather than forking their semantics.
flybus = { path = "../flybus" }
ryu-js.workspace = true
serde_json = { workspace = true }
sha2 = { workspace = true }

View file

@ -0,0 +1,103 @@
# fly-session-types
The executable schemas of the session framework: domain scalars, closed enums, method
payloads, canonical JSON, canonical digests and the step trace format.
This crate is CONTRACT-01 of
[`docs/design/session-framework/implementation.md`](../../../../docs/design/session-framework/implementation.md).
It holds no transport, no worker, no coordinator and no store; it never opens a socket or a
file other than its own fixtures. The bus owns the wire
([`flybus`](../flybus)), and this crate owns what the messages mean.
## Layout
| Module | Contents |
| --- | --- |
| `scalar` | `Scope`, `RationalNs`, `SchemaRef`, `TypedValue`, the `DomainType` trait, and `BusCallId` / `DomainRequestId` / `ArtifactIdentity` / `OwnerToken` |
| `canonical` | RFC 8785 canonical JSON, SHA-256 digests, `OperationKey`, canonical bodies, the 64-KiB envelope check |
| `rpc` | `SessionRpcRequest`, `SessionRpcSuccess`, `SessionRpcFailure`, `ErrorCode`, `MutationCertainty` |
| `workers` | The closed enums and every Agent/Environment/Worker method payload of workers-v1 |
| `media` | `ViewDescriptor`, `ViewRef`, `AudioDescriptor`, `AudioRef` and the `State.*` payloads |
| `publishing` | `SessionDescriptor` and `CommittedSnapshot` |
| `trace` | `TraceBehaviour`, `TraceOperational`, `TransitionTrace` and the behaviour comparator |
| `schema` | The canonical schema set and `contract_digest()` |
| `seed` | `seed-derivation-v1` |
| `checkpoint` | The `FLYSESS1` envelope layout |
| `fixtures` | Loading `fixtures/`, shared with `packages/session-types` |
`Id`, `U64` and `Digest` are the bus encodings: `scalar` calls into `flybus::wire` instead of
restating them, and `tests/encodings.rs` pins that the two agree for every edge case.
## Reading and validating
Every type implements `DomainType`:
```rust
use fly_session_types::scalar::{DomainType, Scope};
let scope = Scope::from_json(&value)?; // reads, refusing unknown fields, then validates
scope.validate()?; // the cross-field rules, re-runnable
let json = scope.to_json(); // the canonical shape
```
Rules that need another value in hand are separate, because a payload cannot check them alone:
```rust
control.validate_against(&port.controls)?; // complete batch, descriptor order, ranges
input.validate_against(&descriptor.views)?; // max(0, boundary - observationDelaySteps)
result.validate_against(&descriptor, &previous)?; // exactly one stepDuration of world time
snapshot.validate_against(&session_descriptor)?; // revision, agent set, assigned ports
telemetry.validate_against_roles(&profile_roles)?; // rates in profile-defined order
```
## Digests
- `contract_digest()` is the SHA-256 of the canonical schema set (`schema::schema_set()`),
which is a declaration: type names, JSON field names, kinds, bounds and closed enums.
Reformatting this crate cannot change it; changing a field or a bound does.
- `canonical::body_digest(method, scope, params)` is the comparison ipc-v1 section 5 uses to
tell a safe replay from a `CONFLICT`. It refuses a body that carries a bus identity.
- `OperationKey` is `(sessionId, epoch, step, method, workerId)`, and deliberately not the
request id: a changed id for an existing key is the conflict to detect.
## Fixtures
`fixtures/` is loaded by these tests and by `packages/session-types`, so a case is written
once and holds both languages to it.
| File | Contents |
| --- | --- |
| `valid.json` | Payloads every implementation accepts, with their canonical JSON and digest |
| `invalid.json` | Payloads every implementation refuses, each with the rule it breaks |
| `raw.json` | Byte sequences refused before validation: duplicate keys, invalid UTF-8, `NaN`, trailing data |
| `generated.json` | Recipes for payloads too large to store: the 32-KiB and 64-KiB boundaries, 512-code-point messages |
| `boundaries.json` | The `U64` decimal-string and double boundaries |
| `rational.json` | Checked rational arithmetic and the 16, 17, 17 tick accumulator |
| `identities.json` | Which of the four identity types accepts which spelling |
| `descriptor-checks.json` | Rules that need a descriptor: batches, delays, byte shapes, descriptor agreement |
| `operations.json` | Operation keys, canonical bodies and the pairs that are or are not the same operation |
| `traces.json` | A baseline transition and the variants that must or must not compare equal |
| `schema-set.json`, `contract-digest.json` | The canonical schema set and its digest |
| `seed-vectors.json` | `seed-derivation-v1` test vectors |
| `checkpoint-envelope.json` | One `FLYSESS1` envelope, its layout and the corruptions a reader refuses |
The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields
of `valid.json`, the digests in `operations.json`, `seed-vectors.json` and
`checkpoint-envelope.json`) come from
`cargo run -p fly-session-types --example update_fixtures`;
`tests/schema_set.rs` fails if the checked-in files are stale.
## Tests
```sh
cargo test -p fly-session-types
cargo clippy -p fly-session-types --all-targets
```
## Bounds this crate chose
Every bound in the schema set names its source. Six are marked `crate` because no document
states them: `maxAudioStreams` (8), `maxCapabilities` (32), `maxSupportedMajors` (8),
`maxSupportedStimuli` (64), `maxAssets` (64) and `maxSnapshotEvents` (64). They exist so an
unbounded array cannot fill an envelope, and they are in the digest, so widening one is a
contract change rather than a quiet edit.

View file

@ -0,0 +1,290 @@
//! Regenerates the derived fixture files.
//!
//! `cargo run -p fly-session-types --example update_fixtures`. `tests/fixtures_current.rs`
//! fails if the checked-in files differ from what this writes, so the digests in the
//! fixtures can never drift from the code that produced them.
use std::collections::BTreeMap;
use fly_session_types::scalar::{DomainType, Scope};
use fly_session_types::{canonical, checkpoint, fixtures, schema, seed};
use serde_json::{Map, Value, json};
fn main() {
let dir = fixtures::dir();
for (name, contents) in derived() {
let path = dir.join(&name);
std::fs::write(&path, contents).expect("write fixture");
println!("wrote {}", path.display());
}
}
/// Every derived fixture, as `(file name, exact bytes)`.
pub fn derived() -> Vec<(String, String)> {
vec![
("schema-set.json".to_owned(), schema_set()),
("contract-digest.json".to_owned(), contract_digest()),
("valid.json".to_owned(), valid()),
("operations.json".to_owned(), operations()),
("seed-vectors.json".to_owned(), seed_vectors()),
("checkpoint-envelope.json".to_owned(), checkpoint_envelope()),
]
}
fn write(value: &Value) -> String {
let mut text = serde_json::to_string_pretty(value).expect("serializable");
text.push('\n');
text
}
fn schema_set() -> String {
// The rendered set is itself canonical JSON, so the file the TypeScript package hashes is
// byte for byte what the digest was taken over.
let mut text = schema::schema_set_json().expect("canonicalizable");
text.push('\n');
text
}
fn contract_digest() -> String {
let set = schema::schema_set_json().expect("canonicalizable");
write(&json!({
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": schema::contract_digest(),
"schemaSetVersion": schema::SCHEMA_SET_VERSION,
"schemaSetBytes": set.len(),
"types": schema::SCHEMAS.len(),
"enums": schema::ENUMS.len(),
"limits": schema::LIMITS.len(),
}))
}
fn valid() -> String {
let mut file = fixtures::load("valid.json").expect("valid.json");
let cases = file
.get_mut("cases")
.and_then(Value::as_array_mut)
.expect("cases");
for case in cases.iter_mut() {
let value = case.get("value").expect("value").clone();
let canonical = canonical::canonicalize(&value).expect("canonicalizable");
let digest = canonical::sha256_hex(canonical.as_bytes());
let map = case.as_object_mut().expect("case object");
map.insert("canonical".to_owned(), Value::String(canonical));
map.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn operations() -> String {
let mut file = fixtures::load("operations.json").expect("operations.json");
let scope_of = |case: &Value| -> Option<Scope> {
match case.get("scope") {
Some(Value::Null) | None => None,
Some(v) => Some(Scope::from_json(v).expect("scope")),
}
};
for key in file
.get_mut("keys")
.and_then(Value::as_array_mut)
.expect("keys")
{
let scope = scope_of(key).expect("an operation key has a scope");
let method = key.get("method").and_then(Value::as_str).expect("method");
let worker = key.get("workerId").and_then(Value::as_str).expect("workerId");
let digest = canonical::OperationKey::new(scope, method, worker)
.expect("valid key")
.digest()
.expect("digest");
key.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
for body in file
.get_mut("bodies")
.and_then(Value::as_array_mut)
.expect("bodies")
{
let scope = scope_of(body);
let method = body.get("method").and_then(Value::as_str).expect("method");
let params = body.get("params").expect("params").clone();
let digest =
canonical::body_digest(method, scope.as_ref(), &params).expect("canonical body");
body.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn seed_vectors() -> String {
let master_seeds: [u64; 5] = [0, 1, 42, 9_223_372_036_854_775_808, u64::MAX];
let agents = ["fly-a", "fly-b", "fly-c", "fly-d"];
let mut vectors = Vec::new();
for master in master_seeds {
for agent in agents {
let material = seed::material(master, agent).expect("material");
vectors.push(json!({
"masterSeed": master.to_string(),
"agentId": agent,
"material": String::from_utf8(material).expect("utf-8"),
"materialDigest": seed::material_digest(master, agent).expect("digest"),
"seed": seed::agent_seed(master, agent).expect("seed"),
}));
}
}
let composition: Vec<Value> = seed::composition_seeds(
42,
&agents.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
)
.expect("composition")
.into_iter()
.map(Value::from)
.collect();
write(&json!({
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": seed::ALGORITHM,
"prefix": seed::PREFIX,
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": vectors,
"composition": {
"masterSeed": "42",
"agentIds": agents,
"seeds": composition,
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids",
},
"invalid": [
{"masterSeed": "0", "agentId": "Fly-A", "reason": "an agent id is an Id: lowercase"},
{"masterSeed": "0", "agentId": "", "reason": "an agent id is 1..=64 characters"},
{"masterSeed": "0", "agentIds": ["fly-a", "fly-a"],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"},
],
}))
}
fn checkpoint_envelope() -> String {
let scope = Scope::new("demo", "epoch-1", 42).expect("scope");
let manifest = json!({
"envelopeVersion": checkpoint::VERSION,
"checkpointId": "ckpt-1",
"sourceScope": scope.to_json(),
"episodeId": "episode-1",
"worldTime": {"numerator": "700000000", "denominator": "1"},
"schedulerId": "lockstep-v1",
"compositionDigest": canonical::sha256_hex(b"composition"),
"portMap": [{"portId": "port-1", "agentId": "fly-a"}],
"compatibility": {
"backendDigest": canonical::sha256_hex(b"backend"),
"contentDigest": canonical::sha256_hex(b"content"),
"patchDigest": canonical::sha256_hex(b"patch"),
"controllerDigest": canonical::sha256_hex(b"controller"),
"parserDigest": canonical::sha256_hex(b"parser"),
"stateFormatId": "flysess-1",
},
"agents": [{
"agentId": "fly-a",
"profileDigest": canonical::sha256_hex(b"profile"),
"datasetDigest": canonical::sha256_hex(b"fafb-v783"),
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": seed::agent_seed(42, "fly-a").expect("seed"),
"brainTicks": "2534",
"remainder": {"numerator": "1000000", "denominator": "3"},
"payload": "agent-fly-a",
}],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [{"agentId": "fly-a", "payload": "executor-fly-a"}],
"admissionState": null,
"eventWatermarks": {"lastEventId": "evt-1", "lastOrdinal": "7"},
},
"helperState": [],
"payloads": payload_table(),
});
let bytes = checkpoint::encode(&manifest, &payloads()).expect("encode");
let envelope = checkpoint::decode(&bytes).expect("decode");
let entries: Vec<Value> = envelope
.layout
.entries
.iter()
.map(|entry| {
json!({
"name": entry.name,
"offset": entry.offset.to_string(),
"byteLength": entry.byte_length.to_string(),
"digest": checkpoint::hex(&entry.digest),
})
})
.collect();
let first_payload = envelope.layout.entries[0].offset;
write(&json!({
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": checkpoint::VERSION,
"manifest": manifest,
"payloads": payloads()
.iter()
.map(|(name, bytes)| json!({"name": name, "base64": fixtures::encode_base64(bytes)}))
.collect::<Vec<_>>(),
"envelope": {
"base64": fixtures::encode_base64(&bytes),
"byteLength": bytes.len(),
"layout": {
"headerBytes": checkpoint::HEADER_BYTES,
"manifestOffset": envelope.layout.manifest_offset.to_string(),
"manifestBytes": envelope.layout.manifest_bytes,
"tableOffset": envelope.layout.table_offset.to_string(),
"tableEntryBytes": checkpoint::TABLE_ENTRY_BYTES,
"entries": entries,
"footerOffset": envelope.layout.footer_offset.to_string(),
"footerBytes": checkpoint::FOOTER_BYTES,
"totalBytes": envelope.layout.total_bytes.to_string(),
},
},
"corruption": [
{"name": "a flipped magic byte", "offset": 0, "reason": "wrong magic"},
{"name": "an unsupported version", "offset": 8, "reason": "unsupported version"},
{"name": "a flipped manifest byte", "offset": checkpoint::HEADER_BYTES,
"reason": "the footer digest covers the manifest"},
{"name": "a flipped payload byte", "offset": first_payload,
"reason": "every payload carries its own digest"},
{"name": "a flipped footer digest byte", "offset": bytes.len() - 40,
"reason": "the footer digest must match the contents"},
{"name": "a flipped footer magic byte", "offset": bytes.len() - 8,
"reason": "a truncated file cannot look complete"},
],
}))
}
fn payloads() -> Vec<(String, Vec<u8>)> {
vec![
("agent-fly-a".to_owned(), b"agent state bytes".to_vec()),
("executor-fly-a".to_owned(), b"executor state".to_vec()),
("task-ledger".to_owned(), b"{\"rank\":10}".to_vec()),
("prior-inspection".to_owned(), b"{\"map\":40}".to_vec()),
("world".to_owned(), vec![0u8; 64]),
]
}
fn payload_table() -> Value {
let mut out = Vec::new();
for (name, bytes) in payloads() {
let mut entry = Map::new();
entry.insert("name".to_owned(), Value::String(name));
entry.insert(
"byteLength".to_owned(),
Value::String(bytes.len().to_string()),
);
entry.insert(
"digest".to_owned(),
Value::String(canonical::sha256_hex(&bytes)),
);
out.push(Value::Object(entry));
}
// A BTreeMap would sort the payload names; the table order is the write order, which is
// what the envelope records.
let _: BTreeMap<(), ()> = BTreeMap::new();
Value::Array(out)
}

View file

@ -0,0 +1,153 @@
{
"description": "The U64 decimal-string and double boundaries, shared by both languages.",
"u64": [
{
"text": "0",
"accept": true,
"reason": "zero is \"0\""
},
{
"text": "1",
"accept": true,
"reason": ""
},
{
"text": "18446744073709551615",
"accept": true,
"reason": "the U64 maximum"
},
{
"text": "18446744073709551616",
"accept": false,
"reason": "one past the maximum"
},
{
"text": "184467440737095516150",
"accept": false,
"reason": "far past the maximum"
},
{
"text": "00",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "01",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "",
"accept": false,
"reason": "empty"
},
{
"text": "-1",
"accept": false,
"reason": "unsigned"
},
{
"text": "+1",
"accept": false,
"reason": "no sign"
},
{
"text": "1.0",
"accept": false,
"reason": "integers only"
},
{
"text": "1e3",
"accept": false,
"reason": "decimal digits only"
},
{
"text": " 1",
"accept": false,
"reason": "no whitespace"
},
{
"text": "1 ",
"accept": false,
"reason": "no whitespace"
},
{
"text": "0x10",
"accept": false,
"reason": "decimal only"
},
{
"text": "9007199254740993",
"accept": true,
"reason": "a U64 string keeps precision a double would lose"
}
],
"doubles": [
{
"value": 0.0,
"canonical": "0",
"accept": true,
"reason": ""
},
{
"value": -0.0,
"canonical": "0",
"accept": true,
"reason": "JSON.stringify prints negative zero as 0"
},
{
"value": 1.0,
"canonical": "1",
"accept": true,
"reason": "an integral double prints without a fraction"
},
{
"value": 0.1,
"canonical": "0.1",
"accept": true,
"reason": ""
},
{
"value": 1e+21,
"canonical": "1e+21",
"accept": true,
"reason": "ECMAScript switches to exponent notation at 1e21"
},
{
"value": 1e-07,
"canonical": "1e-7",
"accept": true,
"reason": ""
},
{
"value": 5e-324,
"canonical": "5e-324",
"accept": true,
"reason": "the smallest subnormal double"
},
{
"value": 1.7976931348623157e+308,
"canonical": "1.7976931348623157e+308",
"accept": true,
"reason": "the largest finite double"
},
{
"value": 9007199254740991,
"canonical": "9007199254740991",
"accept": true,
"reason": "the largest exactly representable integer"
},
{
"value": 9007199254740993,
"canonical": null,
"accept": false,
"reason": "past the exact integer range, canonical JSON refuses it"
},
{
"value": 0.30000000000000004,
"canonical": "0.30000000000000004",
"accept": true,
"reason": "shortest round-tripping form, not a rounded one"
}
]
}

View file

@ -0,0 +1,195 @@
{
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": 1,
"manifest": {
"envelopeVersion": 1,
"checkpointId": "ckpt-1",
"sourceScope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"episodeId": "episode-1",
"worldTime": {
"numerator": "700000000",
"denominator": "1"
},
"schedulerId": "lockstep-v1",
"compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0",
"portMap": [
{
"portId": "port-1",
"agentId": "fly-a"
}
],
"compatibility": {
"backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be",
"contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"patchDigest": "a4895eb44afc336fecbba6e520cd67e178dace0276655d102fceffa8e5f70570",
"controllerDigest": "c1472135b14c77c8bef98e73f70208325fa0dcf1e6bd668ae9b31a9cea295fe7",
"parserDigest": "b17d45121150928f2146af49e195eff1eef5d67325be273a733fb74acadaa342",
"stateFormatId": "flysess-1"
},
"agents": [
{
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": -184946063,
"brainTicks": "2534",
"remainder": {
"numerator": "1000000",
"denominator": "3"
},
"payload": "agent-fly-a"
}
],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [
{
"agentId": "fly-a",
"payload": "executor-fly-a"
}
],
"admissionState": null,
"eventWatermarks": {
"lastEventId": "evt-1",
"lastOrdinal": "7"
}
},
"helperState": [],
"payloads": [
{
"name": "agent-fly-a",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
]
},
"payloads": [
{
"name": "agent-fly-a",
"base64": "YWdlbnQgc3RhdGUgYnl0ZXM="
},
{
"name": "executor-fly-a",
"base64": "ZXhlY3V0b3Igc3RhdGU="
},
{
"name": "task-ledger",
"base64": "eyJyYW5rIjoxMH0="
},
{
"name": "prior-inspection",
"base64": "eyJtYXAiOjQwfQ=="
},
{
"name": "world",
"base64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
],
"envelope": {
"base64": "RkxZU0VTUzEBAAAAIAAAAAAIAAAFAAAAIAgAAAAAAAB7ImFnZW50cyI6W3siYWdlbnRJZCI6ImZseS1hIiwiYnJhaW5UaWNrcyI6IjI1MzQiLCJkYXRhc2V0RGlnZXN0IjoiNmMwYWYxZjA3ODRlZjYzYTM5M2VlNzdkNjE0ZTgyNDZjNjI1MDUxMzYwZjNmMWE0ODgzODM3NGM1ZDM1NWI1MiIsIm1vZGVsVmVyc2lvbiI6ImxpZi0xbXMtZjY0LXYyIiwicGF5bG9hZCI6ImFnZW50LWZseS1hIiwicGxhc3RpY2l0eVZlcnNpb24iOiJmbHkta2MtbWJvbi1yc3RkcC12MiIsInByb2ZpbGVEaWdlc3QiOiIxOTAwZWFiNmMwMjg0ODNkNzEyNjU5OWVlNmY1MGRlMGQyNzkwN2I1YzY1ZmE5MDUyNDU4MGI0YjBmOTg1MmIwIiwicmVtYWluZGVyIjp7ImRlbm9taW5hdG9yIjoiMyIsIm51bWVyYXRvciI6IjEwMDAwMDAifSwic2VlZCI6LTE4NDk0NjA2M31dLCJjaGVja3BvaW50SWQiOiJja3B0LTEiLCJjb21wYXRpYmlsaXR5Ijp7ImJhY2tlbmREaWdlc3QiOiIxMGUwOGE0MTllODUwZWJhMWViYmExOGZkZDI4ZWI3ZWMxYjdlOGJhYTliY2MzYjk3M2UyYjg4OTFlYzcyNmJlIiwiY29udGVudERpZ2VzdCI6ImVkNzAwMmI0MzllOWFjODQ1ZjIyMzU3ZDgyMmJhYzE0NDQ3MzBmYmRiNjAxNmQzZWM5NDMyMjk3YjllYzlmNzMiLCJjb250cm9sbGVyRGlnZXN0IjoiYzE0NzIxMzViMTRjNzdjOGJlZjk4ZTczZjcwMjA4MzI1ZmEwZGNmMWU2YmQ2NjhhZTliMzFhOWNlYTI5NWZlNyIsInBhcnNlckRpZ2VzdCI6ImIxN2Q0NTEyMTE1MDkyOGYyMTQ2YWY0OWUxOTVlZmYxZWVmNWQ2NzMyNWJlMjczYTczM2ZiNzRhY2FkYWEzNDIiLCJwYXRjaERpZ2VzdCI6ImE0ODk1ZWI0NGFmYzMzNmZlY2JiYTZlNTIwY2Q2N2UxNzhkYWNlMDI3NjY1NWQxMDJmY2VmZmE4ZTVmNzA1NzAiLCJzdGF0ZUZvcm1hdElkIjoiZmx5c2Vzcy0xIn0sImNvbXBvc2l0aW9uRGlnZXN0IjoiNzMwZDcyNWM4YTU5ZDNhNzMwM2RlZjJiZWQwNDFhNTc3ZWRiNDI1NWFhYmQ0ODg5Y2UxMjkxODMxMWQ5NTJmMCIsImNvb3JkaW5hdG9yIjp7ImFkbWlzc2lvblN0YXRlIjpudWxsLCJldmVudFdhdGVybWFya3MiOnsibGFzdEV2ZW50SWQiOiJldnQtMSIsImxhc3RPcmRpbmFsIjoiNyJ9LCJleGVjdXRvclN0YXRlIjpbeyJhZ2VudElkIjoiZmx5LWEiLCJwYXlsb2FkIjoiZXhlY3V0b3ItZmx5LWEifV0sInByaW9ySW5zcGVjdGlvbiI6InByaW9yLWluc3BlY3Rpb24iLCJ0YXNrTGVkZ2VyIjoidGFzay1sZWRnZXIifSwiZW52ZWxvcGVWZXJzaW9uIjoxLCJlcGlzb2RlSWQiOiJlcGlzb2RlLTEiLCJoZWxwZXJTdGF0ZSI6W10sInBheWxvYWRzIjpbeyJieXRlTGVuZ3RoIjoiMTciLCJkaWdlc3QiOiIxMzIxZGZmYjBjZGM2ZjkwOTJjYmY3ZmEyYTVmYzY4YmJlZDEyYzk5M2Q1YWQzOTgyNjQwMTI4MTBjZTliZjkzIiwibmFtZSI6ImFnZW50LWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTQiLCJkaWdlc3QiOiIzYWVlNjBkZjdlMjllZmViYTdmNWY5OWZjNTg2NzY0N2IzNmFlYmZmMWQ1ZDNjODM4ZGJmZjMyMzEyMmU2NDYyIiwibmFtZSI6ImV4ZWN1dG9yLWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTEiLCJkaWdlc3QiOiI0MGIwMGVkMmJiYmE5MDFkNjgyMDVmZjcxYjA0YTQ0YjllZTUzYzUxY2IzMTA5YWEyY2VhYTQ0ZjFjNDU3MjdlIiwibmFtZSI6InRhc2stbGVkZ2VyIn0seyJieXRlTGVuZ3RoIjoiMTAiLCJkaWdlc3QiOiIyYzEzYjdiNGQ5YTk5MTY4MDFhYjkxOTFjMzE0ZjMxYjA0NWU5YjljNWI2NjlhNmMwNDc0ZjAyMTdlZjc1YmY1IiwibmFtZSI6InByaW9yLWluc3BlY3Rpb24ifSx7ImJ5dGVMZW5ndGgiOiI2NCIsImRpZ2VzdCI6ImY1YTVmZDQyZDE2YTIwMzAyNzk4ZWY2ZWQzMDk5NzliNDMwMDNkMjMyMGQ5ZjBlOGVhOTgzMWE5Mjc1OWZiNGIiLCJuYW1lIjoid29ybGQifV0sInBvcnRNYXAiOlt7ImFnZW50SWQiOiJmbHktYSIsInBvcnRJZCI6InBvcnQtMSJ9XSwic2NoZWR1bGVySWQiOiJsb2Nrc3RlcC12MSIsInNvdXJjZVNjb3BlIjp7ImVwb2NoIjoiZXBvY2gtMSIsInNlc3Npb25JZCI6ImRlbW8iLCJzdGVwIjoiNDIifSwid29ybGRUaW1lIjp7ImRlbm9taW5hdG9yIjoiMSIsIm51bWVyYXRvciI6IjcwMDAwMDAwMCJ9fWFnZW50LWZseS1hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQCgAAAAAAABEAAAAAAAAAEyHf+wzcb5CSy/f6Kl/Gi77RLJk9WtOYJkASgQzpv5NleGVjdXRvci1mbHktYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaAoAAAAAAAAOAAAAAAAAADruYN9+Ke/rp/X5n8WGdkezauv/HV08g42/8yMSLmRidGFzay1sZWRnZXIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgKAAAAAAAACwAAAAAAAABAsA7Su7qQHWggX/cbBKRLnuU8UcsxCaos6qRPHEVyfnByaW9yLWluc3BlY3Rpb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACICgAAAAAAAAoAAAAAAAAALBO3tNmpkWgBq5GRwxTzGwRem5xbZppsBHTwIX73W/V3b3JsZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAoAAAAAAABAAAAAAAAAAPWl/ULRaiAwJ5jvbtMJl5tDAD0jINnw6OqYMaknWftLYWdlbnQgc3RhdGUgYnl0ZXMAAAAAAAAAZXhlY3V0b3Igc3RhdGUAAHsicmFuayI6MTB9AAAAAAB7Im1hcCI6NDB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgLAAAAAAAAq++fEx+FvDZho/eB4imbENN4HZrGNC2OCAsI7/gp9r5GTFlTRVNTRg==",
"byteLength": 2824,
"layout": {
"headerBytes": 32,
"manifestOffset": "32",
"manifestBytes": 2048,
"tableOffset": "2080",
"tableEntryBytes": 112,
"entries": [
{
"name": "agent-fly-a",
"offset": "2640",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"offset": "2664",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"offset": "2680",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"offset": "2696",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"offset": "2712",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
],
"footerOffset": "2776",
"footerBytes": 48,
"totalBytes": "2824"
}
},
"corruption": [
{
"name": "a flipped magic byte",
"offset": 0,
"reason": "wrong magic"
},
{
"name": "an unsupported version",
"offset": 8,
"reason": "unsupported version"
},
{
"name": "a flipped manifest byte",
"offset": 32,
"reason": "the footer digest covers the manifest"
},
{
"name": "a flipped payload byte",
"offset": 2640,
"reason": "every payload carries its own digest"
},
{
"name": "a flipped footer digest byte",
"offset": 2784,
"reason": "the footer digest must match the contents"
},
{
"name": "a flipped footer magic byte",
"offset": 2816,
"reason": "a truncated file cannot look complete"
}
]
}

View file

@ -0,0 +1,9 @@
{
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": "7932aef30c4d2d16e428081affc4e0ad187987f5b361138d553e54fd7f843b50",
"schemaSetVersion": 1,
"schemaSetBytes": 26685,
"types": 53,
"enums": 11,
"limits": 25
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
{
"description": "Boundary cases both languages build from a recipe, because the payload is too large to store.",
"padSchema": {
"id": "pad.v1",
"version": 1,
"digest": "018689202f154300eeda48ccee8cc021c36672df03f7404097613f5898fdfdcc"
},
"cases": [
{
"name": "typed value exactly at the 32 KiB cap",
"kind": "padded-typed-value",
"padCharacters": 32635,
"expect": "accept",
"reason": "32768 bytes of canonical JSON is the limit, not one byte less"
},
{
"name": "typed value one byte over the cap",
"kind": "padded-typed-value",
"padCharacters": 32636,
"expect": "reject",
"reason": "a TypedValue is at most 32 KiB of canonical JSON"
},
{
"name": "request that fills the 64 KiB envelope exactly",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "accept",
"reason": "65536 bytes including the envelope wrapper is admissible",
"envelopeTotal": 65536
},
{
"name": "request one byte past the envelope ceiling",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "reject",
"reason": "the complete envelope must fit Flybus's 64-KiB maximum",
"envelopeTotal": 65537
},
{
"name": "error message of 512 code points",
"kind": "error-message",
"codePoints": 512,
"expect": "accept",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 513 code points",
"kind": "error-message",
"codePoints": 513,
"expect": "reject",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 512 astral code points",
"kind": "error-message-astral",
"codePoints": 512,
"expect": "accept",
"reason": "the bound counts code points, not UTF-16 units or bytes"
},
{
"name": "error message of 513 astral code points",
"kind": "error-message-astral",
"codePoints": 513,
"expect": "reject",
"reason": "the bound counts code points, not UTF-16 units or bytes"
}
],
"recipes": {
"padded-typed-value": "a TypedValue whose schema is padSchema and whose value is {\"pad\": <padCharacters> 'a' characters}; validate it",
"padded-request": "a SessionRpcRequest req-1 with scope null and params {\"pad\": <padCharacters> 'a' characters}; canonicalize it, then require the envelope to fit with an overhead of envelopeTotal minus that canonical length",
"error-message": "a SessionRpcFailure with code INTERNAL, mutation unknown and a message of <codePoints> 'x' characters",
"error-message-astral": "the same failure with a message of <codePoints> repetitions of U+10400, one code point and two UTF-16 units each"
}
}

View file

@ -0,0 +1,111 @@
{
"description": "Bus callId, domain requestId, artifact identity and delivery/hold owner tokens are four types, not four spellings of one string.",
"cases": [
{
"text": "call-0",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-102",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "req-41",
"busCallId": false,
"domainRequestId": true,
"ownerToken": null
},
{
"text": "dlv-7",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "delivery"
},
{
"text": "own-9",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "hold"
},
{
"text": "req-041",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "callid-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "request-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "REQ-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "dlv-07",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "sub-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "svc-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
}
],
"artifact": {
"ref": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1",
"byteLength": "92160",
"contentType": "image/x-rgba8",
"digest": null
},
"identity": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1"
},
"reason": "the identity is the naming half of an ArtifactRef; byteLength, contentType and digest are not identity, and an AssetRef is not an artifact at all"
},
"asset": {
"id": "profile-fly-a",
"digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"byteLength": "4096",
"format": "flyprofile"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,667 @@
{
"description": "ipc-v1 section 5: the operation key of a step mutation and the canonical body a duplicate is compared against.",
"keys": [
{
"name": "Agent.Prepare on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "eda73d62bb5d616052997ab5e9d2c4ba5872fdefb44cf706910a18a24d39225f"
},
{
"name": "Agent.Prepare on fly-b at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-b",
"digest": "5a7fd15da236f3fd6343698a8cc5c4f14fa7d84683a819d5a28904d90f700c4e"
},
{
"name": "Agent.Commit on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Commit",
"workerId": "fly-a",
"digest": "2a89a120e5594895d024b4ffc2a7d225bd27322cfd345ebd78e80ed7b271c680"
},
{
"name": "Agent.Prepare on fly-a at step 42",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "5bb184cab6c3c2d7567a7979d860a7e9b26ea52bdc92b139053b0a2f81a9ed4d"
},
{
"name": "Agent.Prepare on fly-a in another epoch",
"scope": {
"sessionId": "demo",
"epoch": "epoch-2",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "fa1d9cf3936aaffe3cedd0e586d7763efca2d250e89dd85b849cf35fbd91a477"
},
{
"name": "Environment.Advance at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Environment.Advance",
"workerId": "world",
"digest": "c89c390aa78ef2895d9e2b86a6000160923dc54e3be75529e0289cd18b9088b1"
}
],
"bodies": [
{
"name": "Agent.Prepare body",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
},
"digest": "74c9d5f27b2cb06922cc3ca13d87c67faea0a6c73aefbbdb21ba8f52c6d33f60"
},
{
"name": "Environment.Advance body",
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
},
"digest": "5aaae9c083336460a2ce34fb50e191f7108444a953f09b952e97a48b7962ee79"
},
{
"name": "Worker.Hello body with no scope",
"method": "Worker.Hello",
"scope": null,
"params": {
"sessionId": "demo",
"expectedWorkerId": "fly-a",
"role": "agent",
"supportedMajors": [
1
]
},
"digest": "0c07677a45178d8680dddbeec56d4e09f6ccbfe5fe77d9d4ff2fb07b369febaf"
}
],
"pairs": [
{
"name": "the same operation retried on a new bus call",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": true,
"reason": "a retry reuses the requestId and body; only the callId changes, and the callId is not in either digest"
},
{
"name": "the same batch id with altered controls",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 1.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": false,
"reason": "same key, changed body: CONFLICT, never a second world mutation"
},
{
"name": "params written with their keys in another order",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
],
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"agentId": "fly-a"
}
},
"workerId": "fly-a",
"sameKey": true,
"sameBody": true,
"reason": "RFC 8785 sorts keys, so serialization order is not a body change"
},
{
"name": "the same body one step later",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"sameKey": false,
"sameBody": false,
"reason": "the step is part of both the key and the body"
},
{
"name": "the same body on another worker",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"rightWorkerId": "fly-b",
"sameKey": false,
"sameBody": true,
"reason": "the worker is part of the key, not of the body"
}
],
"rejected": [
{
"name": "a body carrying a bus callId",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"callId": "call-2",
"agentId": "fly-a"
},
"reason": "the canonical body excludes bus callIds"
},
{
"name": "a body carrying a delivery id",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"deliveryId": "dlv-7"
},
"reason": "the canonical body excludes deliveryIds"
},
{
"name": "a body carrying an owner token",
"method": "Agent.Commit",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"nextInput": {
"frame": {
"ownerId": "own-3"
}
}
},
"reason": "the canonical body excludes owner tokens"
},
{
"name": "a body carrying a pinned service incarnation",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"expectedIncarnation": "svc-1"
},
"reason": "route pinning is transport state, not domain state"
},
{
"name": "an empty method",
"method": "",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {},
"reason": "methods are 1..=128 printable ASCII characters"
}
]
}

View file

@ -0,0 +1,249 @@
{
"description": "Checked rational arithmetic and the step-v1 section 5 tick accumulator.",
"accumulator": [
{
"name": "a synthetic 60 Hz environment on a 1 ms model tick",
"stepDuration": {
"numerator": "50000000",
"denominator": "3"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "2000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "1000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "50",
"reason": "16, 17, 17 and a remainder of zero after three steps"
},
{
"name": "a whole millisecond cadence never accumulates a remainder",
"stepDuration": {
"numerator": "16000000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "32",
"reason": ""
},
{
"name": "a step shorter than one tick advances nothing and keeps the remainder",
"stepDuration": {
"numerator": "500000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "0",
"remainder": {
"numerator": "500000",
"denominator": "1"
}
},
{
"ticks": "1",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "1",
"reason": "the fractional period is carried, not rounded"
}
],
"add": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"sum": {
"numerator": "50000000",
"denominator": "3"
}
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "6"
},
"sum": {
"numerator": "1",
"denominator": "2"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "reduced value does not fit U64"
}
],
"subtract": [
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"difference": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "1",
"denominator": "2"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"difference": {
"numerator": "1",
"denominator": "6"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "subtraction would be negative"
}
],
"multiply": [
{
"a": {
"numerator": "1000000",
"denominator": "1"
},
"k": "17",
"product": {
"numerator": "17000000",
"denominator": "1"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"k": "1000",
"product": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"k": "2",
"error": "reduced value does not fit U64"
}
],
"compare": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "less"
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"ordering": "equal",
"reason": "equal values compare equal; an unreduced 2/6 never reaches a comparison, because it never parses"
},
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "greater"
}
]
}

View file

@ -0,0 +1,77 @@
{
"description": "Byte sequences every implementation must refuse before validation.",
"cases": [
{
"name": "duplicate key at the top level",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEiLCJzdGVwIjoiMiJ9",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "duplicate key inside a nested object",
"type": "TypedValue",
"base64": "eyJzY2hlbWEiOnsiaWQiOiJhLnYxIiwidmVyc2lvbiI6MSwiZGlnZXN0IjoiYmJhNzk1MWMwNDY2NGNkNDliYjZlZWQxZGE2ZGMxYTRlMTdlOTg5ZTc4N2JmMmU3MmY1OTMzYTVjNjE3MTM3MiJ9LCJ2YWx1ZSI6eyJ4IjoxLCJ4IjoyfX0=",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "invalid UTF-8 in a string",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZf9tbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "invalid UTF-8 in a key",
"type": "Scope",
"base64": "eyJzZXNzaW9u/0lkIjoiZGVtbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "NaN literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6TmFOfQ==",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "Infinity literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6SW5maW5pdHl9",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "number that overflows a double",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6MWU5OTl9",
"reason": "a non-finite number never survives parsing"
},
{
"name": "integer past the exact double range",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6OTAwNzE5OTI1NDc0MDk5M30=",
"reason": "canonical JSON cannot encode it exactly; counters are U64 strings"
},
{
"name": "trailing data after the object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEifSB7fQ==",
"reason": "one value per payload"
},
{
"name": "truncated object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIg==",
"reason": "partial JSON is refused"
},
{
"name": "empty payload",
"type": "Scope",
"base64": "",
"reason": "an empty payload is not a JSON object"
},
{
"name": "a bare array",
"type": "Scope",
"base64": "W10=",
"reason": "a payload is an object"
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,185 @@
{
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": "seed-derivation-v1",
"prefix": "flybrain/seed-derivation-v1",
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": [
{
"masterSeed": "0",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n0\nfly-a\n",
"materialDigest": "6cf7c34a422f4cdd890c64d0ef5a072e4f6ddc2ee18e83ee24a5f2214f4960d1",
"seed": 1828176714
},
{
"masterSeed": "0",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n0\nfly-b\n",
"materialDigest": "48a52f4069cfca001ba5ee6ae870dccb5a5bd59d61babf333a01749b6a8f4025",
"seed": 1218785088
},
{
"masterSeed": "0",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n0\nfly-c\n",
"materialDigest": "53a83c2d0cfce5a3356aab3b0b73a270201a2dff7816168a4167c71a32d863fe",
"seed": 1403534381
},
{
"masterSeed": "0",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n0\nfly-d\n",
"materialDigest": "f202513f32fe6070eee65ad72027490f9218b86dc8cf74e4e3563f02d532ea94",
"seed": -234729153
},
{
"masterSeed": "1",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n1\nfly-a\n",
"materialDigest": "6eab9d6d6d002ffc0e2cdf2d64dd8226f90c391e45641512d14edbb244225698",
"seed": 1856740717
},
{
"masterSeed": "1",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n1\nfly-b\n",
"materialDigest": "00115cb341d839490c5485462fd7eedd8b6baeda3748054fcba0d1558e471a6c",
"seed": 1137843
},
{
"masterSeed": "1",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n1\nfly-c\n",
"materialDigest": "af97482074d6d36e5f5920aaa1d87f02365045f3712300dbd243584f2cb4a64c",
"seed": -1349040096
},
{
"masterSeed": "1",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n1\nfly-d\n",
"materialDigest": "1081b24880040dcc4d442f88451513e4fbcf8dae9ccb79329dd289fabbc7938b",
"seed": 276935240
},
{
"masterSeed": "42",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n42\nfly-a\n",
"materialDigest": "f4f9f271d53e94c38c6260b99840513f9eb70e5bb442d63345e217185e6ba9f6",
"seed": -184946063
},
{
"masterSeed": "42",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n42\nfly-b\n",
"materialDigest": "1feb285e834138ec823ffea696fbe5fb3c211f349ce17140e25286d06c56ece8",
"seed": 535504990
},
{
"masterSeed": "42",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n42\nfly-c\n",
"materialDigest": "b5a251ed4521f91eab1d8a6813880794c5c6706eafc137d8ebc169f743c60eed",
"seed": -1247653395
},
{
"masterSeed": "42",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n42\nfly-d\n",
"materialDigest": "e36d6e40f27a4ffe473351d4bd01154da2efb3c3038d88f3aa48f77963d465c6",
"seed": -479367616
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-a\n",
"materialDigest": "d59f513b6fdce1646ed13907302e0cb3ca4696738dd7df38e9581397508ec933",
"seed": -710979269
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-b\n",
"materialDigest": "c8de8ca0279829c438fa19c07f05768cfe050e8c21bdaa0fe3c68f8048b4e1a3",
"seed": -924939104
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-c\n",
"materialDigest": "354ad1ec388aa7be6136151d0f6281cc17279b62e95d458c96f392aaac40ee62",
"seed": 894095852
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-d\n",
"materialDigest": "58e61deb4777b7702bdcd7edf5a5bcd0d51273699cc1051c7fbc9a5ffedd9e15",
"seed": 1491475947
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-a\n",
"materialDigest": "f888eb95bcab276936fce5f72df3a0741e36da26722f43cc2a974932dfd42cd1",
"seed": -125244523
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-b\n",
"materialDigest": "d343df049e8ef71617e9af7a321cf498d61083c50229f2d6852077670c86acbd",
"seed": -750526716
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-c\n",
"materialDigest": "88016b220245b431a4eb510d31b643475bb2496130b65ae81b165acd5a899292",
"seed": -2013172958
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-d\n",
"materialDigest": "d9b5bee718c87599c9ef0b3c16f361dadf714a33f7dd1209a21108f4df700b16",
"seed": -642400537
}
],
"composition": {
"masterSeed": "42",
"agentIds": [
"fly-a",
"fly-b",
"fly-c",
"fly-d"
],
"seeds": [
-184946063,
535504990,
-1247653395,
-479367616
],
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids"
},
"invalid": [
{
"masterSeed": "0",
"agentId": "Fly-A",
"reason": "an agent id is an Id: lowercase"
},
{
"masterSeed": "0",
"agentId": "",
"reason": "an agent id is 1..=64 characters"
},
{
"masterSeed": "0",
"agentIds": [
"fly-a",
"fly-a"
],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,273 @@
//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5.
//!
//! One serialization, two languages: keys sorted by UTF-16 code unit, numbers printed by the
//! ECMAScript `Number::toString` algorithm (so a JavaScript `JSON.stringify` over the same
//! sorted tree produces the same bytes), strings escaped the way `JSON.stringify` escapes
//! them, no insignificant whitespace. A digest is the SHA-256 of those bytes, lowercase hex.
//!
//! Numbers outside the exactly representable double range are refused rather than rounded:
//! every counter and clock in these contracts is a `U64` decimal string, so a JSON number
//! larger than 2^53-1 is a schema error, not something to canonicalize approximately.
use serde_json::{Number, Value};
use sha2::{Digest as _, Sha256};
use crate::scalar::{Result, Scope, err, wire_err};
/// The largest integer a double represents exactly.
pub const MAX_EXACT_INTEGER: i64 = 9_007_199_254_740_991;
/// The bus envelope ceiling every domain message must also fit (bus-v1 section 4).
pub const MAX_ENVELOPE_BYTES: usize = flybus::wire::MAX_ENVELOPE_BYTES;
/// The `f64` a JSON number denotes, or `None` if it is not a finite exactly representable one.
pub fn finite_double(n: &Number) -> Option<f64> {
if let Some(u) = n.as_u64() {
return (u <= MAX_EXACT_INTEGER as u64).then_some(u as f64);
}
if let Some(i) = n.as_i64() {
return (i >= -MAX_EXACT_INTEGER).then_some(i as f64);
}
n.as_f64().filter(|v| v.is_finite())
}
/// `String(number)` for a finite double, the ECMAScript algorithm RFC 8785 requires.
fn number_to_string(value: f64) -> String {
if value == 0.0 {
// Covers -0.0, which `JSON.stringify` prints as "0".
return "0".to_owned();
}
let mut buffer = ryu_js::Buffer::new();
buffer.format(value).to_owned()
}
/// Escapes one string the way `JSON.stringify` does.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\u{09}' => out.push_str("\\t"),
'\u{0a}' => out.push_str("\\n"),
'\u{0c}' => out.push_str("\\f"),
'\u{0d}' => out.push_str("\\r"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// Sorts object keys by UTF-16 code unit, as RFC 8785 section 3.2.3 specifies.
fn utf16_key(key: &str) -> Vec<u16> {
key.encode_utf16().collect()
}
/// The canonical JSON text of `value`.
pub fn canonicalize(value: &Value) -> Result<String> {
let mut out = String::new();
write_value(&mut out, value)?;
Ok(out)
}
/// The canonical JSON bytes of `value`.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
canonicalize(value).map(String::into_bytes)
}
fn write_value(out: &mut String, value: &Value) -> Result<()> {
match value {
Value::Null => out.push_str("null"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Number(n) => {
let d = finite_double(n).ok_or_else(|| {
wire_err(format!(
"canonical JSON: {n} is not a finite number in the exact double range"
))
})?;
out.push_str(&number_to_string(d));
}
Value::String(s) => write_string(out, s),
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_value(out, item)?;
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_by_cached_key(|k| utf16_key(k));
out.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_string(out, key);
out.push(':');
write_value(out, &map[key.as_str()])?;
}
out.push('}');
}
}
Ok(())
}
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes.
pub fn digest_of(value: &Value) -> Result<String> {
canonical_bytes(value).map(|bytes| sha256_hex(&bytes))
}
/// Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, non-finite numbers and
/// trailing bytes are refused. The bus reader, reused so both layers agree byte for byte.
pub fn parse_strict(bytes: &[u8]) -> Result<Value> {
flybus::wire::parse_json_strict(bytes).map_err(|e| wire_err(e.0))
}
/// Refuses a domain payload that does not fit the bus envelope ceiling.
///
/// The check is on canonical bytes, and the caller passes the overhead the surrounding
/// envelope adds, so a payload that only fits without its envelope still fails.
pub fn require_envelope_fit(value: &Value, envelope_overhead: usize) -> Result<usize> {
let len = canonicalize(value)?.len();
let total = len + envelope_overhead;
if total > MAX_ENVELOPE_BYTES {
return err(format!(
"envelope: {total} bytes exceeds the {MAX_ENVELOPE_BYTES}-byte maximum"
));
}
Ok(total)
}
// ---------------------------------------------------------------------------------------------
// Operation keys and canonical bodies
/// The keys that belong to the bus, never to a domain body (ipc-v1 section 5: the canonical
/// body "excludes changing bus callIds, deliveryIds and owner tokens").
pub const BUS_ONLY_KEYS: &[&str] = &[
"callId",
"deliveryId",
"ownerId",
"ownerIds",
"deliveryIds",
"requestDeliveryId",
"expectedIncarnation",
"serviceIncarnation",
"connectionId",
"topicSequence",
"subscriptionId",
];
/// Fails if any bus-only key appears anywhere in `value`.
pub fn reject_bus_identities(value: &Value) -> Result<()> {
match value {
Value::Object(map) => {
for (key, inner) in map {
if BUS_ONLY_KEYS.contains(&key.as_str()) {
return err(format!(
"canonical body: {key:?} is a bus identity and never part of a domain body"
));
}
reject_bus_identities(inner)?;
}
Ok(())
}
Value::Array(items) => {
for item in items {
reject_bus_identities(item)?;
}
Ok(())
}
_ => Ok(()),
}
}
/// `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation.
///
/// There is at most one Prepare, Commit or Advance for one key (ipc-v1 section 5). The key
/// deliberately does not contain the requestId: a changed id for an existing key is CONFLICT,
/// which can only be detected if the key is the same.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OperationKey {
pub scope: Scope,
pub method: String,
pub worker_id: String,
}
impl OperationKey {
pub fn new(scope: Scope, method: &str, worker_id: &str) -> Result<OperationKey> {
let key = OperationKey {
scope,
method: method.to_owned(),
worker_id: worker_id.to_owned(),
};
key.validate()?;
Ok(key)
}
pub fn validate(&self) -> Result<()> {
use crate::scalar::DomainType;
self.scope.validate()?;
if !flybus::wire::is_method(&self.method) {
return err("OperationKey: method must be 1..=128 printable ASCII characters");
}
if !crate::scalar::is_id(&self.worker_id) {
return err("OperationKey: workerId is not a valid id");
}
Ok(())
}
pub fn to_json(&self) -> Value {
use crate::scalar::DomainType;
crate::scalar::obj(vec![
("scope", self.scope.to_json()),
("method", self.method.clone().into()),
("workerId", self.worker_id.clone().into()),
])
}
/// The canonical digest of the key, for a deduplication table that stores digests.
pub fn digest(&self) -> Result<String> {
digest_of(&self.to_json())
}
}
/// The canonical body of a domain operation: method, scope and validated params.
///
/// Two calls of the same operation key whose body digests differ are CONFLICT; two calls with
/// the same digest are the same operation, whatever bus callId carried them.
pub fn canonical_body(method: &str, scope: Option<&Scope>, params: &Value) -> Result<Value> {
if !flybus::wire::is_method(method) {
return err("canonical body: method must be 1..=128 printable ASCII characters");
}
if !params.is_object() {
return err("canonical body: params must be an object");
}
reject_bus_identities(params)?;
Ok(crate::scalar::obj(vec![
("method", method.into()),
("scope", Scope::nullable_to_json(scope)),
("params", params.clone()),
]))
}
/// The canonical body digest of a domain operation.
pub fn body_digest(method: &str, scope: Option<&Scope>, params: &Value) -> Result<String> {
digest_of(&canonical_body(method, scope, params)?)
}

View file

@ -0,0 +1,368 @@
//! `FLYSESS1`: the envelope layout of `docs/design/session-framework/checkpoint-envelope-v1.md`.
//!
//! This is the layout half of the specification, not the store: it lays out a header, a
//! canonical-JSON manifest, a payload table and the payload bytes, and it reads one back.
//! Writing generations, fsyncing and committing a manifest belong to the STATE-01 store slice.
//! `FLYSIM01` is a different format with a different magic and is not touched by any of this.
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use crate::canonical;
use crate::scalar::{Result, err, is_id};
/// Envelope magic. Eight ASCII bytes, distinct from `FLYSIM01`.
pub const MAGIC: &[u8; 8] = b"FLYSESS1";
/// Footer magic, so a truncated file cannot look complete.
pub const FOOTER_MAGIC: &[u8; 8] = b"FLYSESSF";
/// Envelope version, in the header and in the manifest.
pub const VERSION: u32 = 1;
/// Fixed header size in bytes.
pub const HEADER_BYTES: usize = 32;
/// One payload table entry: a 64-byte name field, offset, length and a 32-byte digest.
pub const TABLE_ENTRY_BYTES: usize = 112;
/// Payload name field width.
pub const NAME_BYTES: usize = 64;
/// Footer size in bytes: total length, whole-prefix digest and the footer magic.
pub const FOOTER_BYTES: usize = 48;
/// Payloads start on an eight-byte boundary.
pub const ALIGNMENT: u64 = 8;
/// Payloads per envelope.
pub const MAX_PAYLOADS: usize = 64;
/// One payload's table entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadEntry {
/// An `Id`: the new envelope widens the historical letters-only chunk name deliberately,
/// which is why it is a new version and not an extension of `FLYSIM01`.
pub name: String,
pub offset: u64,
pub byte_length: u64,
/// SHA-256 of exactly `byte_length` bytes at `offset`.
pub digest: [u8; 32],
}
/// A laid-out envelope: where everything is, before any bytes are written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub manifest_offset: u64,
pub manifest_bytes: u32,
pub table_offset: u64,
pub entries: Vec<PayloadEntry>,
pub footer_offset: u64,
pub total_bytes: u64,
}
fn align_up(value: u64) -> u64 {
value.div_ceil(ALIGNMENT) * ALIGNMENT
}
/// Lays out the envelope for one manifest and a list of `(name, bytes)` payloads.
pub fn layout(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Layout> {
if payloads.len() > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
crate::scalar::require_unique(
payloads.iter().map(|(name, _)| name.as_str()),
"checkpoint envelope: payload names",
)?;
for (name, _) in payloads {
if !is_id(name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
}
let manifest_text = canonical::canonicalize(manifest)?;
let manifest_bytes = u32::try_from(manifest_text.len())
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: manifest is too large"))?;
let manifest_offset = HEADER_BYTES as u64;
let table_offset = align_up(manifest_offset + u64::from(manifest_bytes));
let mut offset = align_up(table_offset + (payloads.len() * TABLE_ENTRY_BYTES) as u64);
let mut entries = Vec::with_capacity(payloads.len());
for (name, bytes) in payloads {
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length: bytes.len() as u64,
digest: Sha256::digest(bytes).into(),
});
offset = align_up(offset + bytes.len() as u64);
}
Ok(Layout {
manifest_offset,
manifest_bytes,
table_offset,
entries,
footer_offset: offset,
total_bytes: offset + FOOTER_BYTES as u64,
})
}
/// Writes one envelope: header, manifest, payload table, payloads, footer.
pub fn encode(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
let layout = layout(manifest, payloads)?;
let manifest_text = canonical::canonicalize(manifest)?;
let mut out = vec![0u8; layout.footer_offset as usize];
out[0..8].copy_from_slice(MAGIC);
out[8..12].copy_from_slice(&VERSION.to_le_bytes());
out[12..16].copy_from_slice(&(HEADER_BYTES as u32).to_le_bytes());
out[16..20].copy_from_slice(&layout.manifest_bytes.to_le_bytes());
out[20..24].copy_from_slice(&(payloads.len() as u32).to_le_bytes());
out[24..28].copy_from_slice(&(layout.table_offset as u32).to_le_bytes());
out[28..32].copy_from_slice(&0u32.to_le_bytes());
let manifest_start = layout.manifest_offset as usize;
out[manifest_start..manifest_start + manifest_text.len()]
.copy_from_slice(manifest_text.as_bytes());
for (index, entry) in layout.entries.iter().enumerate() {
let base = layout.table_offset as usize + index * TABLE_ENTRY_BYTES;
out[base..base + entry.name.len()].copy_from_slice(entry.name.as_bytes());
let numbers = base + NAME_BYTES;
out[numbers..numbers + 8].copy_from_slice(&entry.offset.to_le_bytes());
out[numbers + 8..numbers + 16].copy_from_slice(&entry.byte_length.to_le_bytes());
out[numbers + 16..numbers + 48].copy_from_slice(&entry.digest);
}
for (entry, (_, bytes)) in layout.entries.iter().zip(payloads) {
let start = entry.offset as usize;
out[start..start + bytes.len()].copy_from_slice(bytes);
}
let digest: [u8; 32] = Sha256::digest(&out).into();
out.extend_from_slice(&layout.total_bytes.to_le_bytes());
out.extend_from_slice(&digest);
out.extend_from_slice(FOOTER_MAGIC);
Ok(out)
}
/// A decoded envelope.
#[derive(Clone, Debug, PartialEq)]
pub struct Envelope {
pub manifest: Value,
pub payloads: Vec<(String, Vec<u8>)>,
pub layout: Layout,
}
impl Envelope {
pub fn payload(&self, name: &str) -> Option<&[u8]> {
self.payloads
.iter()
.find(|(key, _)| key == name)
.map(|(_, bytes)| bytes.as_slice())
}
}
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
fn u64_at(bytes: &[u8], offset: usize) -> u64 {
let mut buf = [0u8; 8];
buf.copy_from_slice(&bytes[offset..offset + 8]);
u64::from_le_bytes(buf)
}
/// Reads and fully validates one envelope: magic, version, footer digest, table ordering,
/// alignment, bounds and every payload digest.
pub fn decode(bytes: &[u8]) -> Result<Envelope> {
if bytes.len() < HEADER_BYTES + FOOTER_BYTES {
return err("checkpoint envelope: shorter than a header plus a footer");
}
if &bytes[0..8] != MAGIC {
return err("checkpoint envelope: wrong magic (FLYSIM01 is a different format)");
}
if u32_at(bytes, 8) != VERSION {
return err("checkpoint envelope: unsupported version");
}
if u32_at(bytes, 12) as usize != HEADER_BYTES {
return err("checkpoint envelope: headerBytes must be 32");
}
if u32_at(bytes, 28) != 0 {
return err("checkpoint envelope: reserved header word must be zero");
}
let manifest_bytes = u32_at(bytes, 16) as usize;
let payload_count = u32_at(bytes, 20) as usize;
let table_offset = u32_at(bytes, 24) as u64;
if payload_count > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
let footer_offset = bytes.len() - FOOTER_BYTES;
if &bytes[footer_offset + 40..] != FOOTER_MAGIC {
return err("checkpoint envelope: missing footer magic");
}
if u64_at(bytes, footer_offset) != bytes.len() as u64 {
return err("checkpoint envelope: footer length does not match the file");
}
let recorded = &bytes[footer_offset + 8..footer_offset + 40];
let computed: [u8; 32] = Sha256::digest(&bytes[..footer_offset]).into();
if recorded != computed {
return err("checkpoint envelope: footer digest does not match the contents");
}
let manifest_start = HEADER_BYTES;
let manifest_end = manifest_start + manifest_bytes;
if manifest_end > footer_offset {
return err("checkpoint envelope: manifest runs past the payload area");
}
let manifest = canonical::parse_strict(&bytes[manifest_start..manifest_end])?;
let canonical_manifest = canonical::canonicalize(&manifest)?;
if canonical_manifest.as_bytes() != &bytes[manifest_start..manifest_end] {
return err("checkpoint envelope: the manifest is not canonical JSON");
}
if table_offset != align_up(manifest_end as u64) {
return err("checkpoint envelope: the payload table is not at its laid-out offset");
}
let table_end = table_offset as usize + payload_count * TABLE_ENTRY_BYTES;
if table_end > footer_offset {
return err("checkpoint envelope: the payload table runs past the payload area");
}
let mut entries = Vec::with_capacity(payload_count);
let mut payloads = Vec::with_capacity(payload_count);
let mut previous_end = align_up(table_end as u64);
for index in 0..payload_count {
let base = table_offset as usize + index * TABLE_ENTRY_BYTES;
let name_field = &bytes[base..base + NAME_BYTES];
let length = name_field
.iter()
.position(|b| *b == 0)
.unwrap_or(NAME_BYTES);
if name_field[length..].iter().any(|b| *b != 0) {
return err("checkpoint envelope: a payload name has bytes after its terminator");
}
let name = std::str::from_utf8(&name_field[..length])
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: payload name is not UTF-8"))?
.to_owned();
if !is_id(&name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
let numbers = base + NAME_BYTES;
let offset = u64_at(bytes, numbers);
let byte_length = u64_at(bytes, numbers + 8);
let mut digest = [0u8; 32];
digest.copy_from_slice(&bytes[numbers + 16..numbers + 48]);
if offset != previous_end {
return err(format!(
"checkpoint envelope: payload {name:?} starts at {offset}, not at its aligned {previous_end}"
));
}
let end = offset
.checked_add(byte_length)
.ok_or_else(|| crate::scalar::wire_err("checkpoint envelope: payload overflows"))?;
if end > footer_offset as u64 {
return err(format!(
"checkpoint envelope: payload {name:?} runs past the payload area"
));
}
let payload = bytes[offset as usize..end as usize].to_vec();
let computed: [u8; 32] = Sha256::digest(&payload).into();
if computed != digest {
return err(format!(
"checkpoint envelope: payload {name:?} fails its digest"
));
}
previous_end = align_up(end);
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length,
digest,
});
payloads.push((name, payload));
}
crate::scalar::require_unique(
entries.iter().map(|e| e.name.as_str()),
"checkpoint envelope: payload names",
)?;
if previous_end != footer_offset as u64 {
return err("checkpoint envelope: padding between the last payload and the footer");
}
Ok(Envelope {
manifest,
layout: Layout {
manifest_offset: manifest_start as u64,
manifest_bytes: manifest_bytes as u32,
table_offset,
entries,
footer_offset: footer_offset as u64,
total_bytes: bytes.len() as u64,
},
payloads,
})
}
/// The manifest fields state-media-v1 section 4 requires, checked as a set: a manifest that
/// omits one of them is not a complete checkpoint.
pub const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"envelopeVersion",
"checkpointId",
"sourceScope",
"episodeId",
"worldTime",
"schedulerId",
"compositionDigest",
"portMap",
"compatibility",
"agents",
"coordinator",
"payloads",
];
/// Checks the manifest's required field set and that its payload table mirrors the envelope's.
pub fn validate_manifest(envelope: &Envelope) -> Result<()> {
let map = envelope
.manifest
.as_object()
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: must be an object"))?;
for field in REQUIRED_MANIFEST_FIELDS {
if !map.contains_key(*field) {
return err(format!("checkpoint manifest: missing {field:?}"));
}
}
if map.get("envelopeVersion").and_then(Value::as_u64) != Some(u64::from(VERSION)) {
return err("checkpoint manifest: envelopeVersion must be 1");
}
let listed = map
.get("payloads")
.and_then(Value::as_array)
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: payloads must be an array"))?;
if listed.len() != envelope.layout.entries.len() {
return err("checkpoint manifest: payloads does not match the payload table");
}
for (declared, entry) in listed.iter().zip(&envelope.layout.entries) {
let name = declared.get("name").and_then(Value::as_str);
let length = declared
.get("byteLength")
.and_then(Value::as_str)
.and_then(crate::scalar::parse_u64);
let digest = declared.get("digest").and_then(Value::as_str);
if name != Some(entry.name.as_str()) {
return err("checkpoint manifest: payload name does not match the table");
}
if length != Some(entry.byte_length) {
return err(format!(
"checkpoint manifest: payload {:?} byteLength does not match the table",
entry.name
));
}
if digest != Some(hex(&entry.digest).as_str()) {
return err(format!(
"checkpoint manifest: payload {:?} digest does not match the table",
entry.name
));
}
}
Ok(())
}
/// Lowercase hex of a raw digest, the form the manifest records.
pub fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}

View file

@ -0,0 +1,108 @@
//! Loading the crate's `fixtures/` directory.
//!
//! The same files are read by the Rust tests and by `packages/session-types`, so a case only
//! has to be written once to hold both languages to it.
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::canonical;
use crate::scalar::{Result, err, wire_err};
/// The crate's `fixtures/` directory.
pub fn dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures")
}
/// Reads one fixture file, parsed strictly.
pub fn load(name: &str) -> Result<Value> {
let path = dir().join(name);
let bytes =
std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display())))?;
canonical::parse_strict(&bytes)
}
/// Reads one fixture file as raw bytes, for the cases that are deliberately not valid JSON.
pub fn load_bytes(name: &str) -> Result<Vec<u8>> {
let path = dir().join(name);
std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display())))
}
/// The `cases` array of a fixture file.
pub fn cases(file: &Value) -> Result<&Vec<Value>> {
match file.get("cases").and_then(Value::as_array) {
Some(cases) if !cases.is_empty() => Ok(cases),
_ => err("fixture: cases must be a nonempty array"),
}
}
/// A string field of one case.
pub fn field<'a>(case: &'a Value, key: &str) -> Result<&'a str> {
case.get(key)
.and_then(Value::as_str)
.ok_or_else(|| wire_err(format!("fixture case: missing string field {key:?}")))
}
/// Decodes the `base64` field of a case that carries raw bytes.
pub fn base64(case: &Value, key: &str) -> Result<Vec<u8>> {
decode_base64(field(case, key)?)
}
/// Standard base64 with padding. Small and local: the crate has no base64 dependency and the
/// fixtures only carry a few hundred bytes.
pub fn decode_base64(text: &str) -> Result<Vec<u8>> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = text.as_bytes();
if !bytes.len().is_multiple_of(4) {
return err("base64: length must be a multiple of 4");
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for quad in bytes.chunks_exact(4) {
let mut buffer = 0u32;
let mut keep = 3;
for (index, byte) in quad.iter().enumerate() {
let value = if *byte == b'=' {
if index < 2 {
return err("base64: misplaced padding");
}
keep -= 1;
0
} else {
ALPHABET
.iter()
.position(|c| c == byte)
.ok_or_else(|| wire_err("base64: invalid character"))? as u32
};
buffer = (buffer << 6) | value;
}
let triple = buffer.to_be_bytes();
out.extend_from_slice(&triple[1..1 + keep]);
}
Ok(out)
}
/// Standard base64 with padding, for generating fixtures.
pub fn encode_base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let mut buffer = [0u8; 3];
buffer[..chunk.len()].copy_from_slice(chunk);
let value = u32::from_be_bytes([0, buffer[0], buffer[1], buffer[2]]);
let indexes = [
(value >> 18) & 0x3f,
(value >> 12) & 0x3f,
(value >> 6) & 0x3f,
value & 0x3f,
];
for (position, index) in indexes.iter().enumerate() {
if position <= chunk.len() {
out.push(ALPHABET[*index as usize] as char);
} else {
out.push('=');
}
}
}
out
}

View file

@ -0,0 +1,56 @@
//! `fly-session-types`: the executable schemas of the session framework (CONTRACT-01).
//!
//! What this crate is:
//!
//! - the domain scalars of [ipc-v1] section 2 ([`scalar`]), reusing the bus's `Id`, `U64` and
//! `Digest` encodings rather than restating them;
//! the domain request/reply envelope and error codes of sections 3 and 7 ([`rpc`]);
//! - the closed enums and method payloads of [workers-v1] ([`workers`]), the native media
//! and State.* payloads of [state-media-v1] ([`media`]), and the publication types of
//! [publishing-v1] ([`publishing`]);
//! - canonical JSON (RFC 8785), canonical digests, the operation key and the canonical body
//! rules of ipc-v1 section 5 ([`canonical`]);
//! - the documented canonical schema set and `contractDigest` ([`schema`]);
//! - the trace format of [step-v1] section 8, with behaviour separated from operational
//! metadata and a comparator over behaviour alone ([`trace`]);
//! - `seed-derivation-v1` ([`seed`]) and the `FLYSESS1` checkpoint envelope layout
//! ([`checkpoint`]), the two specifications CONTRACT-01 has to settle before the real-agent
//! and store slices.
//!
//! What it is not: a transport, a worker, a coordinator or a store. It holds no Game Boy FFI,
//! no Melee parser and no console-specific state, and it never reaches the network.
//!
//! Every type implements [`scalar::DomainType`]: `from_json` reads and validates, `to_json`
//! writes the canonical shape, and `validate` re-checks the rules that span fields. Reading
//! refuses unknown fields, so a payload with a misspelled required field fails instead of
//! silently defaulting.
//!
//! [ipc-v1]: ../../../../docs/design/session-framework/ipc-v1.md
//! [workers-v1]: ../../../../docs/design/session-framework/workers-v1.md
//! [state-media-v1]: ../../../../docs/design/session-framework/state-media-v1.md
//! [publishing-v1]: ../../../../docs/design/session-framework/publishing-v1.md
//! [step-v1]: ../../../../docs/design/session-framework/step-v1.md
pub mod canonical;
pub mod checkpoint;
pub mod fixtures;
pub mod media;
pub mod publishing;
pub mod rpc;
pub mod scalar;
pub mod schema;
pub mod seed;
pub mod trace;
pub mod workers;
pub use canonical::{OperationKey, body_digest, canonicalize, digest_of};
pub use scalar::{
ArtifactIdentity, BusCallId, DomainRequestId, DomainType, OwnerKind, OwnerToken, RationalNs,
SchemaRef, Scope, TypedValue,
};
pub use schema::contract_digest;
pub use trace::{TraceBehaviour, TraceOperational, TransitionTrace};
/// The bus `ArtifactRef` these contracts reference. Re-exported so a consumer does not have
/// to decide whether the domain has its own copy: it does not.
pub use flybus::wire::ArtifactRef;

View file

@ -0,0 +1,716 @@
//! Native observation media (state-media-v1 section 2) and the State.* payloads (section 5).
//!
//! Descriptors carry the shape; refs carry one produced object. Both are validated against
//! the descriptor, because a ref on its own cannot know its own row stride: use
//! [`ViewRef::validate_against`] and [`AudioRef::validate_against`] wherever the descriptor
//! is in hand.
use flybus::wire::{ArtifactRef, Fields};
use serde_json::Value;
use crate::scalar::{
DomainType, RationalNs, Result, Scope, constant, err, finite_in, is_digest, is_id, list, obj,
require_unique, u64_json,
};
/// Max views per sensory input (workers-v1 section 1). The same bound applies to a
/// descriptor's view list and to an observation's view lists: a descriptor that declared more
/// views than one sensory input can carry could not be satisfied.
pub const MAX_VIEWS: usize = 8;
/// View dimensions are integers 1..=4096 (state-media-v1 section 2).
pub const MAX_VIEW_DIMENSION: u64 = 4096;
/// Pixel aspect numerator/denominator are positive integers <=65535.
pub const MAX_PIXEL_ASPECT: u64 = 65_535;
/// observationDelaySteps is an integer 0..=8.
pub const MAX_OBSERVATION_DELAY_STEPS: u64 = 8;
/// sampleFrames is 0..=192000 per chunk; sampleRate is 8000..=192000.
pub const MAX_SAMPLE_FRAMES: u64 = 192_000;
/// Audio streams per descriptor. Not a stated bound: chosen so an envelope cannot be filled
/// with descriptors, and recorded in the schema set so it cannot drift silently.
pub const MAX_AUDIO_STREAMS: usize = 8;
/// `ViewDescriptor`: the fixed shape of one native view.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ViewDescriptor {
pub view_id: String,
pub width: u64,
pub height: u64,
pub row_stride: u64,
pub pixel_aspect_numerator: u64,
pub pixel_aspect_denominator: u64,
pub observation_delay_steps: u64,
}
impl ViewDescriptor {
/// The exact byte length of one frame of this view.
pub fn frame_bytes(&self) -> u64 {
self.row_stride * self.height
}
/// The producing boundary a required sensory view must have at `boundary`
/// (state-media-v1 section 2): `max(0, boundary - observationDelaySteps)`.
pub fn required_produced_step(&self, boundary: u64) -> u64 {
boundary.saturating_sub(self.observation_delay_steps)
}
}
impl DomainType for ViewDescriptor {
const TYPE_NAME: &'static str = "ViewDescriptor";
fn from_json(value: &Value) -> Result<ViewDescriptor> {
let mut f = Fields::new(value, "ViewDescriptor")?;
let view_id = f.id("viewId")?;
let width = f.int("width", 1, MAX_VIEW_DIMENSION)?;
let height = f.int("height", 1, MAX_VIEW_DIMENSION)?;
constant(&mut f, "format", "rgba8")?;
let row_stride = f.int("rowStride", 1, MAX_VIEW_DIMENSION * 4)?;
let aspect = f.value("pixelAspect")?;
let (pixel_aspect_numerator, pixel_aspect_denominator) = {
let mut a = Fields::new(aspect, "ViewDescriptor.pixelAspect")?;
let n = a.int("numerator", 1, MAX_PIXEL_ASPECT)?;
let d = a.int("denominator", 1, MAX_PIXEL_ASPECT)?;
a.finish()?;
(n, d)
};
let observation_delay_steps =
f.int("observationDelaySteps", 0, MAX_OBSERVATION_DELAY_STEPS)?;
f.finish()?;
let d = ViewDescriptor {
view_id,
width,
height,
row_stride,
pixel_aspect_numerator,
pixel_aspect_denominator,
observation_delay_steps,
};
d.validate()?;
Ok(d)
}
fn to_json(&self) -> Value {
obj(vec![
("viewId", self.view_id.clone().into()),
("width", Value::from(self.width)),
("height", Value::from(self.height)),
("format", "rgba8".into()),
("rowStride", Value::from(self.row_stride)),
(
"pixelAspect",
obj(vec![
("numerator", Value::from(self.pixel_aspect_numerator)),
("denominator", Value::from(self.pixel_aspect_denominator)),
]),
),
(
"observationDelaySteps",
Value::from(self.observation_delay_steps),
),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.view_id) {
return err("ViewDescriptor: viewId is not a valid id");
}
if !(1..=MAX_VIEW_DIMENSION).contains(&self.width)
|| !(1..=MAX_VIEW_DIMENSION).contains(&self.height)
{
return err("ViewDescriptor: width and height must be integers 1..=4096");
}
if self.row_stride != self.width * 4 {
return err(
"ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)",
);
}
if !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_numerator)
|| !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_denominator)
{
return err("ViewDescriptor: pixelAspect parts must be positive integers <=65535");
}
if self.observation_delay_steps > MAX_OBSERVATION_DELAY_STEPS {
return err("ViewDescriptor: observationDelaySteps must be 0..=8");
}
Ok(())
}
}
/// `ViewRef`: one produced frame of one view.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ViewRef {
pub view_id: String,
pub produced_step: u64,
pub pixels: ArtifactRef,
}
impl ViewRef {
/// Byte shape and producing boundary against the descriptor that declared this view.
///
/// `boundary` is the observation's boundary; a required sensory view must have been
/// produced at exactly `max(0, boundary - observationDelaySteps)`.
pub fn validate_against(
&self,
descriptor: &ViewDescriptor,
boundary: Option<u64>,
) -> Result<()> {
if self.view_id != descriptor.view_id {
return err(format!(
"ViewRef: viewId {:?} does not match descriptor {:?}",
self.view_id, descriptor.view_id
));
}
if self.pixels.byte_length != descriptor.frame_bytes() {
return err(format!(
"ViewRef {}: artifact is {} bytes, rowStride x height is {}",
self.view_id,
self.pixels.byte_length,
descriptor.frame_bytes()
));
}
if let Some(boundary) = boundary {
let expected = descriptor.required_produced_step(boundary);
if self.produced_step != expected {
return err(format!(
"ViewRef {}: producedStep {} must be max(0, {boundary} - {}) = {expected}",
self.view_id, self.produced_step, descriptor.observation_delay_steps
));
}
}
Ok(())
}
}
impl DomainType for ViewRef {
const TYPE_NAME: &'static str = "ViewRef";
fn from_json(value: &Value) -> Result<ViewRef> {
let mut f = Fields::new(value, "ViewRef")?;
let view_id = f.id("viewId")?;
let produced_step = f.u64_string("producedStep")?;
let pixels = ArtifactRef::from_json(f.value("pixels")?)?;
f.finish()?;
let r = ViewRef {
view_id,
produced_step,
pixels,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("viewId", self.view_id.clone().into()),
("producedStep", u64_json(self.produced_step)),
("pixels", self.pixels.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.view_id) {
return err("ViewRef: viewId is not a valid id");
}
if self.pixels.byte_length == 0 {
return err("ViewRef: pixels must have a positive byte length");
}
Ok(())
}
}
/// `AudioDescriptor`: one native audio stream's shape.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AudioDescriptor {
pub stream_id: String,
pub sample_rate: u64,
pub channels: u64,
}
impl AudioDescriptor {
/// The exact byte length of `frames` interleaved f32 frames.
pub fn chunk_bytes(&self, frames: u64) -> u64 {
frames * self.channels * 4
}
}
impl DomainType for AudioDescriptor {
const TYPE_NAME: &'static str = "AudioDescriptor";
fn from_json(value: &Value) -> Result<AudioDescriptor> {
let mut f = Fields::new(value, "AudioDescriptor")?;
let stream_id = f.id("streamId")?;
let sample_rate = f.int("sampleRate", 8_000, 192_000)?;
let channels = f.int("channels", 1, 8)?;
constant(&mut f, "format", "f32le-interleaved")?;
f.finish()?;
let d = AudioDescriptor {
stream_id,
sample_rate,
channels,
};
d.validate()?;
Ok(d)
}
fn to_json(&self) -> Value {
obj(vec![
("streamId", self.stream_id.clone().into()),
("sampleRate", Value::from(self.sample_rate)),
("channels", Value::from(self.channels)),
("format", "f32le-interleaved".into()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.stream_id) {
return err("AudioDescriptor: streamId is not a valid id");
}
if !(8_000..=192_000).contains(&self.sample_rate) {
return err("AudioDescriptor: sampleRate must be an integer 8000..=192000");
}
if !(1..=8).contains(&self.channels) {
return err("AudioDescriptor: channels must be an integer 1..=8");
}
Ok(())
}
}
/// `AudioRef`: one produced chunk of one audio stream.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AudioRef {
pub stream_id: String,
pub first_sample: u64,
pub sample_frames: u64,
pub samples: ArtifactRef,
pub discontinuity: bool,
}
impl AudioRef {
/// Byte shape against the descriptor that declared this stream.
pub fn validate_against(&self, descriptor: &AudioDescriptor) -> Result<()> {
if self.stream_id != descriptor.stream_id {
return err(format!(
"AudioRef: streamId {:?} does not match descriptor {:?}",
self.stream_id, descriptor.stream_id
));
}
let expected = descriptor.chunk_bytes(self.sample_frames);
if self.samples.byte_length != expected {
return err(format!(
"AudioRef {}: artifact is {} bytes, sampleFrames x channels x 4 is {expected}",
self.stream_id, self.samples.byte_length
));
}
Ok(())
}
/// Within an epoch chunks cannot overlap or go backwards (state-media-v1 section 2).
pub fn follows(&self, previous: &AudioRef) -> Result<()> {
if self.stream_id != previous.stream_id {
return err("AudioRef: chunks of different streams are not ordered against each other");
}
let expected = previous.first_sample + previous.sample_frames;
if self.first_sample < expected {
return err(format!(
"AudioRef {}: firstSample {} overlaps the previous chunk, which ends at {expected}",
self.stream_id, self.first_sample
));
}
Ok(())
}
}
impl DomainType for AudioRef {
const TYPE_NAME: &'static str = "AudioRef";
fn from_json(value: &Value) -> Result<AudioRef> {
let mut f = Fields::new(value, "AudioRef")?;
let stream_id = f.id("streamId")?;
let first_sample = f.u64_string("firstSample")?;
let sample_frames = f.int("sampleFrames", 0, MAX_SAMPLE_FRAMES)?;
let samples = ArtifactRef::from_json(f.value("samples")?)?;
let discontinuity = f.boolean("discontinuity")?;
f.finish()?;
let r = AudioRef {
stream_id,
first_sample,
sample_frames,
samples,
discontinuity,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("streamId", self.stream_id.clone().into()),
("firstSample", u64_json(self.first_sample)),
("sampleFrames", Value::from(self.sample_frames)),
("samples", self.samples.to_json()),
("discontinuity", Value::Bool(self.discontinuity)),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.stream_id) {
return err("AudioRef: streamId is not a valid id");
}
if self.sample_frames > MAX_SAMPLE_FRAMES {
return err("AudioRef: sampleFrames must be an integer 0..=192000");
}
if self.first_sample.checked_add(self.sample_frames).is_none() {
return err("AudioRef: firstSample + sampleFrames overflows U64");
}
Ok(())
}
}
/// Reads a bounded, unique-by-`viewId` list of view refs.
pub fn view_list(f: &mut Fields<'_>, key: &'static str) -> Result<Vec<ViewRef>> {
let views = list(f, key, 0, MAX_VIEWS, ViewRef::from_json)?;
require_unique(views.iter().map(|v| v.view_id.as_str()), key)?;
Ok(views)
}
/// Reads a bounded, unique-by-`streamId` list of audio refs.
pub fn audio_list(f: &mut Fields<'_>, key: &'static str) -> Result<Vec<AudioRef>> {
let audio = list(f, key, 0, MAX_AUDIO_STREAMS, AudioRef::from_json)?;
require_unique(audio.iter().map(|a| a.stream_id.as_str()), key)?;
Ok(audio)
}
// ---------------------------------------------------------------------------------------------
// State.* payloads (state-media-v1 section 5)
/// A checkpoint payload artifact: the digest is mandatory on checkpoint payloads
/// (state-media-v1 section 1).
fn checkpoint_payload(f: &mut Fields<'_>, key: &'static str) -> Result<ArtifactRef> {
let reference = ArtifactRef::from_json(f.value(key)?)?;
match &reference.digest {
Some(d) if is_digest(d) => Ok(reference),
_ => err(format!(
"{key}: a checkpoint payload must carry a content digest"
)),
}
}
/// `State.Capture` params.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaptureParams {
pub checkpoint_id: String,
}
impl DomainType for CaptureParams {
const TYPE_NAME: &'static str = "CaptureParams";
fn from_json(value: &Value) -> Result<CaptureParams> {
let mut f = Fields::new(value, "CaptureParams")?;
let checkpoint_id = f.id("checkpointId")?;
f.finish()?;
let p = CaptureParams { checkpoint_id };
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![("checkpointId", self.checkpoint_id.clone().into())])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.checkpoint_id) {
return err("CaptureParams: checkpointId is not a valid id");
}
Ok(())
}
}
/// `State.Capture` result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaptureResult {
pub checkpoint_id: String,
pub boundary: u64,
pub compatibility_digest: String,
pub payload: ArtifactRef,
}
impl DomainType for CaptureResult {
const TYPE_NAME: &'static str = "CaptureResult";
fn from_json(value: &Value) -> Result<CaptureResult> {
let mut f = Fields::new(value, "CaptureResult")?;
let checkpoint_id = f.id("checkpointId")?;
let boundary = f.u64_string("boundary")?;
let compatibility_digest = f.string("compatibilityDigest")?.to_owned();
let payload = checkpoint_payload(&mut f, "payload")?;
f.finish()?;
let r = CaptureResult {
checkpoint_id,
boundary,
compatibility_digest,
payload,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("checkpointId", self.checkpoint_id.clone().into()),
("boundary", u64_json(self.boundary)),
(
"compatibilityDigest",
self.compatibility_digest.clone().into(),
),
("payload", self.payload.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.checkpoint_id) {
return err("CaptureResult: checkpointId is not a valid id");
}
if !is_digest(&self.compatibility_digest) {
return err("CaptureResult: compatibilityDigest must be 64 lowercase hex digits");
}
match &self.payload.digest {
Some(d) if is_digest(d) => Ok(()),
_ => err("CaptureResult: a checkpoint payload must carry a content digest"),
}
}
}
/// `State.StageRestore` params. The scope is the source boundary, under a proposed new epoch.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StageRestoreParams {
pub checkpoint_id: String,
pub source_scope: Scope,
pub compatibility_digest: String,
pub payload: ArtifactRef,
}
impl DomainType for StageRestoreParams {
const TYPE_NAME: &'static str = "StageRestoreParams";
fn from_json(value: &Value) -> Result<StageRestoreParams> {
let mut f = Fields::new(value, "StageRestoreParams")?;
let checkpoint_id = f.id("checkpointId")?;
let source_scope = Scope::from_json(f.value("sourceScope")?)?;
let compatibility_digest = f.string("compatibilityDigest")?.to_owned();
let payload = checkpoint_payload(&mut f, "payload")?;
f.finish()?;
let p = StageRestoreParams {
checkpoint_id,
source_scope,
compatibility_digest,
payload,
};
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![
("checkpointId", self.checkpoint_id.clone().into()),
("sourceScope", self.source_scope.to_json()),
(
"compatibilityDigest",
self.compatibility_digest.clone().into(),
),
("payload", self.payload.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.checkpoint_id) {
return err("StageRestoreParams: checkpointId is not a valid id");
}
self.source_scope.validate()?;
if !is_digest(&self.compatibility_digest) {
return err("StageRestoreParams: compatibilityDigest must be 64 lowercase hex digits");
}
match &self.payload.digest {
Some(d) if is_digest(d) => Ok(()),
_ => err("StageRestoreParams: a checkpoint payload must carry a content digest"),
}
}
}
/// `State.StageRestore` result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StageRestoreResult {
pub checkpoint_id: String,
pub restore_token: String,
}
impl DomainType for StageRestoreResult {
const TYPE_NAME: &'static str = "StageRestoreResult";
fn from_json(value: &Value) -> Result<StageRestoreResult> {
let mut f = Fields::new(value, "StageRestoreResult")?;
let checkpoint_id = f.id("checkpointId")?;
let restore_token = f.id("restoreToken")?;
f.finish()?;
let r = StageRestoreResult {
checkpoint_id,
restore_token,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("checkpointId", self.checkpoint_id.clone().into()),
("restoreToken", self.restore_token.clone().into()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.checkpoint_id) || !is_id(&self.restore_token) {
return err("StageRestoreResult: checkpointId and restoreToken must be valid ids");
}
Ok(())
}
}
/// `State.ActivateRestore` params.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActivateRestoreParams {
pub restore_token: String,
}
impl DomainType for ActivateRestoreParams {
const TYPE_NAME: &'static str = "ActivateRestoreParams";
fn from_json(value: &Value) -> Result<ActivateRestoreParams> {
let mut f = Fields::new(value, "ActivateRestoreParams")?;
let restore_token = f.id("restoreToken")?;
f.finish()?;
let p = ActivateRestoreParams { restore_token };
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![("restoreToken", self.restore_token.clone().into())])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.restore_token) {
return err("ActivateRestoreParams: restoreToken is not a valid id");
}
Ok(())
}
}
/// `State.ActivateRestore` result. The observation is required from an environment and null
/// from an agent (state-media-v1 section 5); which one applies is the caller's role, so the
/// role-specific check is [`ActivateRestoreResult::validate_for_role`].
#[derive(Clone, Debug, PartialEq)]
pub struct ActivateRestoreResult {
pub committed_step: u64,
pub checkpoint_id: String,
pub observation: Option<crate::workers::WorldObservation>,
}
impl ActivateRestoreResult {
pub fn validate_for_role(&self, role: crate::workers::Role) -> Result<()> {
self.validate()?;
match (role, &self.observation) {
(crate::workers::Role::Environment, None) => {
err("ActivateRestoreResult: an environment must return its restored observation")
}
(crate::workers::Role::Agent, Some(_)) => {
err("ActivateRestoreResult: an agent returns a null observation")
}
_ => Ok(()),
}
}
}
impl DomainType for ActivateRestoreResult {
const TYPE_NAME: &'static str = "ActivateRestoreResult";
fn from_json(value: &Value) -> Result<ActivateRestoreResult> {
let mut f = Fields::new(value, "ActivateRestoreResult")?;
let committed_step = f.u64_string("committedStep")?;
let checkpoint_id = f.id("checkpointId")?;
let observation = match f.value("observation")? {
Value::Null => None,
v => Some(crate::workers::WorldObservation::from_json(v)?),
};
f.finish()?;
let r = ActivateRestoreResult {
committed_step,
checkpoint_id,
observation,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("committedStep", u64_json(self.committed_step)),
("checkpointId", self.checkpoint_id.clone().into()),
(
"observation",
self.observation
.as_ref()
.map_or(Value::Null, |o| o.to_json()),
),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.checkpoint_id) {
return err("ActivateRestoreResult: checkpointId is not a valid id");
}
if let Some(observation) = &self.observation {
observation.validate()?;
if observation.boundary != self.committed_step {
return err(
"ActivateRestoreResult: the observation boundary must be the committed step",
);
}
}
Ok(())
}
}
/// The pixel aspect of a view as a rational, for presentation.
pub fn pixel_aspect(descriptor: &ViewDescriptor) -> Result<RationalNs> {
RationalNs::reduced(
u128::from(descriptor.pixel_aspect_numerator),
u128::from(descriptor.pixel_aspect_denominator),
)
}
/// Audio presentation timestamp, `firstSample / sampleRate` seconds, as a checked rational.
pub fn audio_pts(reference: &AudioRef, descriptor: &AudioDescriptor) -> Result<RationalNs> {
RationalNs::reduced(
u128::from(reference.first_sample),
u128::from(descriptor.sample_rate),
)
}
/// Samples must be finite f32 (state-media-v1 section 2). The bytes live in an artifact, so
/// this is the check a reader runs over a mapped chunk.
pub fn require_finite_samples(bytes: &[u8]) -> Result<()> {
if !bytes.len().is_multiple_of(4) {
return err("audio chunk: length must be a multiple of 4");
}
for (index, chunk) in bytes.chunks_exact(4).enumerate() {
let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
if !sample.is_finite() {
return err(format!("audio chunk: sample {index} is not finite"));
}
}
Ok(())
}
/// A unit-range helper for presentation code that needs the neutral-in-range rule.
pub fn require_unit(f: &mut Fields<'_>, key: &'static str) -> Result<f64> {
finite_in(f, key, 0.0, 1.0)
}

View file

@ -0,0 +1,450 @@
//! The publication types of publishing-v1 section 3.
//!
//! A descriptor changes rarely and a snapshot changes every boundary; both are published on
//! the same bus, and a snapshot names the descriptor revision it was shaped by.
use flybus::wire::Fields;
use serde_json::Value;
use crate::media::{AudioRef, MAX_VIEWS, ViewRef, audio_list, view_list};
use crate::scalar::{
DomainType, RationalNs, Result, SchemaRef, Scope, TypedValue, constant, err, id_list,
is_digest, is_id, list, obj, require_unique, u64_json,
};
use crate::workers::{
AgentTelemetry, AssetRef, EnvironmentDescriptor, MAX_AGENTS, MAX_RATE_ROLES, PortControl,
};
/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set.
pub const MAX_SUPPORTED_STIMULI: usize = 64;
/// Installed assets in one descriptor. Not a stated bound; recorded in the schema set.
pub const MAX_ASSETS: usize = 64;
/// Scoped event ids in one snapshot. Not a stated bound; recorded in the schema set.
pub const MAX_SNAPSHOT_EVENTS: usize = 64;
/// One agent's place in the composition.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentDescriptor {
pub agent_id: String,
pub port_id: String,
pub profile_digest: String,
pub dataset_digest: String,
pub index_digest: String,
pub neuron_count: u64,
pub rate_roles: Vec<String>,
pub supported_stimuli: Vec<String>,
}
/// `SessionDescriptor`: the framework shape of one running session.
#[derive(Clone, Debug, PartialEq)]
pub struct SessionDescriptor {
pub session_id: String,
pub revision: u64,
pub composition_digest: String,
pub environment: EnvironmentDescriptor,
pub task_schema: SchemaRef,
pub agents: Vec<AgentDescriptor>,
pub assets: Vec<AssetRef>,
}
impl DomainType for SessionDescriptor {
const TYPE_NAME: &'static str = "SessionDescriptor";
fn from_json(value: &Value) -> Result<SessionDescriptor> {
let mut f = Fields::new(value, "SessionDescriptor")?;
let session_id = f.id("sessionId")?;
let revision = f.u64_string("revision")?;
let composition_digest = f.string("compositionDigest")?.to_owned();
constant(&mut f, "schedulerId", "lockstep-v1")?;
let environment = EnvironmentDescriptor::from_json(f.value("environment")?)?;
let task_schema = SchemaRef::from_json(f.value("taskSchema")?)?;
let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| {
let mut a = Fields::new(v, "SessionDescriptor.agents")?;
let agent_id = a.id("agentId")?;
let port_id = a.id("portId")?;
let profile_digest = a.string("profileDigest")?.to_owned();
let dataset_digest = a.string("datasetDigest")?.to_owned();
let index_digest = a.string("indexDigest")?.to_owned();
let neuron_count = a.u64_string("neuronCount")?;
let rate_roles = id_list(&mut a, "rateRoles", 0, MAX_RATE_ROLES)?;
let supported_stimuli = id_list(&mut a, "supportedStimuli", 0, MAX_SUPPORTED_STIMULI)?;
a.finish()?;
Ok(AgentDescriptor {
agent_id,
port_id,
profile_digest,
dataset_digest,
index_digest,
neuron_count,
rate_roles,
supported_stimuli,
})
})?;
let assets = list(&mut f, "assets", 0, MAX_ASSETS, AssetRef::from_json)?;
f.finish()?;
let d = SessionDescriptor {
session_id,
revision,
composition_digest,
environment,
task_schema,
agents,
assets,
};
d.validate()?;
Ok(d)
}
fn to_json(&self) -> Value {
obj(vec![
("sessionId", self.session_id.clone().into()),
("revision", u64_json(self.revision)),
("compositionDigest", self.composition_digest.clone().into()),
("schedulerId", "lockstep-v1".into()),
("environment", self.environment.to_json()),
("taskSchema", self.task_schema.to_json()),
(
"agents",
Value::Array(
self.agents
.iter()
.map(|a| {
obj(vec![
("agentId", a.agent_id.clone().into()),
("portId", a.port_id.clone().into()),
("profileDigest", a.profile_digest.clone().into()),
("datasetDigest", a.dataset_digest.clone().into()),
("indexDigest", a.index_digest.clone().into()),
("neuronCount", u64_json(a.neuron_count)),
(
"rateRoles",
Value::Array(
a.rate_roles.iter().map(|r| r.clone().into()).collect(),
),
),
(
"supportedStimuli",
Value::Array(
a.supported_stimuli
.iter()
.map(|s| s.clone().into())
.collect(),
),
),
])
})
.collect(),
),
),
(
"assets",
Value::Array(self.assets.iter().map(AssetRef::to_json).collect()),
),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.session_id) {
return err("SessionDescriptor: sessionId is not a valid id");
}
if !is_digest(&self.composition_digest) {
return err("SessionDescriptor: compositionDigest must be 64 lowercase hex digits");
}
self.environment.validate()?;
self.task_schema.validate()?;
if self.agents.is_empty() || self.agents.len() > MAX_AGENTS {
return err("SessionDescriptor: 1..=4 agents in the first composition");
}
require_unique(
self.agents.iter().map(|a| a.agent_id.as_str()),
"SessionDescriptor.agents agentId",
)?;
require_unique(
self.agents.iter().map(|a| a.port_id.as_str()),
"SessionDescriptor.agents portId",
)?;
for agent in &self.agents {
if !is_id(&agent.agent_id) || !is_id(&agent.port_id) {
return err("SessionDescriptor: agentId and portId must be valid ids");
}
for (what, digest) in [
("profileDigest", &agent.profile_digest),
("datasetDigest", &agent.dataset_digest),
("indexDigest", &agent.index_digest),
] {
if !is_digest(digest) {
return err(format!(
"SessionDescriptor: agent {what} must be 64 lowercase hex digits"
));
}
}
if agent.rate_roles.len() > MAX_RATE_ROLES {
return err("SessionDescriptor: at most 64 rate roles per agent");
}
require_unique(
agent.rate_roles.iter().map(String::as_str),
"SessionDescriptor.agents rateRoles",
)?;
require_unique(
agent.supported_stimuli.iter().map(String::as_str),
"SessionDescriptor.agents supportedStimuli",
)?;
if self.environment.port(&agent.port_id).is_none() {
return err(format!(
"SessionDescriptor: agent {:?} is bound to port {:?}, which the environment does not declare",
agent.agent_id, agent.port_id
));
}
}
require_unique(
self.assets.iter().map(|a| a.id.as_str()),
"SessionDescriptor.assets",
)?;
for asset in &self.assets {
asset.validate()?;
}
Ok(())
}
}
/// One agent's committed values in a snapshot.
#[derive(Clone, Debug, PartialEq)]
pub struct SnapshotAgent {
pub agent_id: String,
pub telemetry: AgentTelemetry,
pub selected_decision: Option<TypedValue>,
pub applied_controls: Option<PortControl>,
}
/// `CommittedSnapshot`: the values of one committed boundary.
#[derive(Clone, Debug, PartialEq)]
pub struct CommittedSnapshot {
pub descriptor_revision: u64,
pub publisher_incarnation: String,
pub scope: Scope,
pub episode_id: String,
pub sequence: u64,
pub world_time: RationalNs,
pub agents: Vec<SnapshotAgent>,
pub progress: TypedValue,
pub views: Vec<ViewRef>,
pub audio: Vec<AudioRef>,
pub event_ids: Vec<String>,
}
impl CommittedSnapshot {
/// Descriptor agreement: the revision, the agent set and the port each control names.
pub fn validate_against(&self, descriptor: &SessionDescriptor) -> Result<()> {
self.validate()?;
if self.descriptor_revision != descriptor.revision {
return err("CommittedSnapshot: descriptorRevision does not match the descriptor");
}
if self.scope.session_id != descriptor.session_id {
return err("CommittedSnapshot: sessionId does not match the descriptor");
}
for agent in &self.agents {
let declared = descriptor
.agents
.iter()
.find(|a| a.agent_id == agent.agent_id)
.ok_or_else(|| {
crate::scalar::wire_err(format!(
"CommittedSnapshot: agent {:?} is not in the descriptor",
agent.agent_id
))
})?;
agent
.telemetry
.validate_against_roles(&declared.rate_roles)?;
if let Some(controls) = &agent.applied_controls {
if controls.port_id != declared.port_id {
return err(format!(
"CommittedSnapshot: agent {:?} controls port {:?}, not its assigned {:?}",
agent.agent_id, controls.port_id, declared.port_id
));
}
let port = descriptor
.environment
.port(&declared.port_id)
.ok_or_else(|| {
crate::scalar::wire_err("CommittedSnapshot: assigned port is not declared")
})?;
controls.validate_against(&port.controls)?;
}
}
Ok(())
}
}
impl DomainType for CommittedSnapshot {
const TYPE_NAME: &'static str = "CommittedSnapshot";
fn from_json(value: &Value) -> Result<CommittedSnapshot> {
let mut f = Fields::new(value, "CommittedSnapshot")?;
let descriptor_revision = f.u64_string("descriptorRevision")?;
let publisher_incarnation = f.id("publisherIncarnation")?;
let scope = Scope::from_json(f.value("scope")?)?;
let episode_id = f.id("episodeId")?;
let sequence = f.u64_string("sequence")?;
let world_time = RationalNs::from_json(f.value("worldTime")?)?;
let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| {
let mut a = Fields::new(v, "CommittedSnapshot.agents")?;
let agent_id = a.id("agentId")?;
let telemetry = AgentTelemetry::from_json(a.value("telemetry")?)?;
let selected_decision = TypedValue::nullable_from_json(a.value("selectedDecision")?)?;
let applied_controls = match a.value("appliedControls")? {
Value::Null => None,
v => Some(PortControl::from_json(v)?),
};
a.finish()?;
Ok(SnapshotAgent {
agent_id,
telemetry,
selected_decision,
applied_controls,
})
})?;
let progress = TypedValue::from_json(f.value("progress")?)?;
let (views, audio) = {
let v = f.value("media")?;
let mut m = Fields::new(v, "CommittedSnapshot.media")?;
let views = view_list(&mut m, "views")?;
let audio = audio_list(&mut m, "audio")?;
m.finish()?;
(views, audio)
};
let event_ids = id_list(&mut f, "eventIds", 0, MAX_SNAPSHOT_EVENTS)?;
f.finish()?;
let s = CommittedSnapshot {
descriptor_revision,
publisher_incarnation,
scope,
episode_id,
sequence,
world_time,
agents,
progress,
views,
audio,
event_ids,
};
s.validate()?;
Ok(s)
}
fn to_json(&self) -> Value {
obj(vec![
("descriptorRevision", u64_json(self.descriptor_revision)),
(
"publisherIncarnation",
self.publisher_incarnation.clone().into(),
),
("scope", self.scope.to_json()),
("episodeId", self.episode_id.clone().into()),
("sequence", u64_json(self.sequence)),
("worldTime", self.world_time.to_json()),
(
"agents",
Value::Array(
self.agents
.iter()
.map(|a| {
obj(vec![
("agentId", a.agent_id.clone().into()),
("telemetry", a.telemetry.to_json()),
(
"selectedDecision",
TypedValue::nullable_to_json(a.selected_decision.as_ref()),
),
(
"appliedControls",
a.applied_controls
.as_ref()
.map_or(Value::Null, PortControl::to_json),
),
])
})
.collect(),
),
),
("progress", self.progress.to_json()),
(
"media",
obj(vec![
(
"views",
Value::Array(self.views.iter().map(ViewRef::to_json).collect()),
),
(
"audio",
Value::Array(self.audio.iter().map(AudioRef::to_json).collect()),
),
]),
),
(
"eventIds",
Value::Array(self.event_ids.iter().map(|e| e.clone().into()).collect()),
),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.publisher_incarnation) || !is_id(&self.episode_id) {
return err("CommittedSnapshot: publisherIncarnation and episodeId must be valid ids");
}
self.scope.validate()?;
self.world_time.validate()?;
if self.agents.is_empty() || self.agents.len() > MAX_AGENTS {
return err("CommittedSnapshot: 1..=4 agents");
}
require_unique(
self.agents.iter().map(|a| a.agent_id.as_str()),
"CommittedSnapshot.agents",
)?;
for agent in &self.agents {
if !is_id(&agent.agent_id) {
return err("CommittedSnapshot: agentId is not a valid id");
}
agent.telemetry.validate()?;
if let Some(decision) = &agent.selected_decision {
decision.validate()?;
}
if let Some(controls) = &agent.applied_controls {
controls.validate()?;
}
// "Decisions/controls describe the transition ending at that boundary, null at
// initial boundary 0." (publishing-v1 section 3)
if self.scope.step == 0
&& (agent.selected_decision.is_some() || agent.applied_controls.is_some())
{
return err(
"CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null",
);
}
if self.scope.step > 0
&& (agent.selected_decision.is_none() || agent.applied_controls.is_none())
{
return err(
"CommittedSnapshot: past boundary 0 every agent has a decision and applied controls",
);
}
}
self.progress.validate()?;
if self.views.len() > MAX_VIEWS {
return err("CommittedSnapshot: at most 8 views");
}
require_unique(
self.views.iter().map(|v| v.view_id.as_str()),
"CommittedSnapshot.media.views",
)?;
require_unique(
self.audio.iter().map(|a| a.stream_id.as_str()),
"CommittedSnapshot.media.audio",
)?;
require_unique(
self.event_ids.iter().map(String::as_str),
"CommittedSnapshot.eventIds",
)?;
Ok(())
}
}

View file

@ -0,0 +1,420 @@
//! The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7.
//!
//! A domain reply is the `outcome` object inside a bus `rpc.result`. Bus route or admission
//! failure is not one of these: it never reaches a handler, so it cannot carry a mutation
//! certainty.
use flybus::wire::Fields;
use serde_json::Value;
use crate::canonical;
use crate::scalar::{
DomainRequestId, DomainType, Result, Scope, bounded_string, constant, enumeration, err, is_id,
obj,
};
use crate::workers::MAX_MESSAGE_CODE_POINTS;
/// The domain error codes of ipc-v1 section 7.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ErrorCode {
/// Invalid schema/range, before mutation.
InvalidArgument,
/// Missing method or capability.
Unsupported,
/// Wrong session/profile/port/build/asset identity.
IdentityMismatch,
StaleEpoch,
StaleStep,
FutureStep,
/// Wrong worker phase.
InvalidPhase,
/// Existing logical operation with a changed id or body.
Conflict,
/// The original operation is still executing; this duplicate bus call started no work.
InProgress,
/// Domain capacity unavailable before admission.
Busy,
/// Missing, unowned or mismatched artifact, or an invalid media shape.
BufferInvalid,
/// Safe replay is no longer available; never recompute to replace it.
ResultExpired,
/// Restore validation failed before activation.
IncompatibleState,
BackendFailure,
Internal,
}
impl ErrorCode {
pub const ALL: &'static [&'static str] = &[
"INVALID_ARGUMENT",
"UNSUPPORTED",
"IDENTITY_MISMATCH",
"STALE_EPOCH",
"STALE_STEP",
"FUTURE_STEP",
"INVALID_PHASE",
"CONFLICT",
"IN_PROGRESS",
"BUSY",
"BUFFER_INVALID",
"RESULT_EXPIRED",
"INCOMPATIBLE_STATE",
"BACKEND_FAILURE",
"INTERNAL",
];
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::InvalidArgument => "INVALID_ARGUMENT",
ErrorCode::Unsupported => "UNSUPPORTED",
ErrorCode::IdentityMismatch => "IDENTITY_MISMATCH",
ErrorCode::StaleEpoch => "STALE_EPOCH",
ErrorCode::StaleStep => "STALE_STEP",
ErrorCode::FutureStep => "FUTURE_STEP",
ErrorCode::InvalidPhase => "INVALID_PHASE",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::InProgress => "IN_PROGRESS",
ErrorCode::Busy => "BUSY",
ErrorCode::BufferInvalid => "BUFFER_INVALID",
ErrorCode::ResultExpired => "RESULT_EXPIRED",
ErrorCode::IncompatibleState => "INCOMPATIBLE_STATE",
ErrorCode::BackendFailure => "BACKEND_FAILURE",
ErrorCode::Internal => "INTERNAL",
}
}
pub fn parse(s: &str) -> Result<ErrorCode> {
Ok(match s {
"INVALID_ARGUMENT" => ErrorCode::InvalidArgument,
"UNSUPPORTED" => ErrorCode::Unsupported,
"IDENTITY_MISMATCH" => ErrorCode::IdentityMismatch,
"STALE_EPOCH" => ErrorCode::StaleEpoch,
"STALE_STEP" => ErrorCode::StaleStep,
"FUTURE_STEP" => ErrorCode::FutureStep,
"INVALID_PHASE" => ErrorCode::InvalidPhase,
"CONFLICT" => ErrorCode::Conflict,
"IN_PROGRESS" => ErrorCode::InProgress,
"BUSY" => ErrorCode::Busy,
"BUFFER_INVALID" => ErrorCode::BufferInvalid,
"RESULT_EXPIRED" => ErrorCode::ResultExpired,
"INCOMPATIBLE_STATE" => ErrorCode::IncompatibleState,
"BACKEND_FAILURE" => ErrorCode::BackendFailure,
"INTERNAL" => ErrorCode::Internal,
_ => return err("code is not one of the fifteen domain error codes"),
})
}
/// The codes that are raised strictly before any mutation, so their certainty is `none`.
pub fn is_before_mutation(self) -> bool {
matches!(
self,
ErrorCode::InvalidArgument
| ErrorCode::Unsupported
| ErrorCode::IdentityMismatch
| ErrorCode::StaleEpoch
| ErrorCode::StaleStep
| ErrorCode::FutureStep
| ErrorCode::InvalidPhase
| ErrorCode::Conflict
| ErrorCode::InProgress
| ErrorCode::Busy
| ErrorCode::BufferInvalid
| ErrorCode::IncompatibleState
)
}
}
/// How certain the responder is that the operation mutated state.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MutationCertainty {
/// Nothing was applied.
None,
/// The mutation completed.
Applied,
/// Completion is not established. "Errors after partial mutation use unknown unless
/// completion is established." (ipc-v1 section 7)
Unknown,
}
impl MutationCertainty {
pub const ALL: &'static [&'static str] = &["none", "applied", "unknown"];
pub fn as_str(self) -> &'static str {
match self {
MutationCertainty::None => "none",
MutationCertainty::Applied => "applied",
MutationCertainty::Unknown => "unknown",
}
}
pub fn parse(s: &str) -> Result<MutationCertainty> {
match s {
"none" => Ok(MutationCertainty::None),
"applied" => Ok(MutationCertainty::Applied),
"unknown" => Ok(MutationCertainty::Unknown),
_ => err("mutation must be none, applied or unknown"),
}
}
}
/// `SessionRpcRequest`: one domain operation, independent of the bus callId that carries it.
#[derive(Clone, Debug, PartialEq)]
pub struct SessionRpcRequest {
pub request_id: DomainRequestId,
pub scope: Option<Scope>,
pub params: Value,
}
impl SessionRpcRequest {
/// The canonical body digest of this request under `method` (ipc-v1 section 5).
pub fn body_digest(&self, method: &str) -> Result<String> {
canonical::body_digest(method, self.scope.as_ref(), &self.params)
}
/// The operation key of a step mutation issued by `worker_id` under `method`. Lifecycle
/// calls with a null scope have no step operation key.
pub fn operation_key(&self, method: &str, worker_id: &str) -> Result<canonical::OperationKey> {
let scope = self
.scope
.clone()
.ok_or_else(|| crate::scalar::wire_err("operation key: a step mutation has a scope"))?;
canonical::OperationKey::new(scope, method, worker_id)
}
}
impl DomainType for SessionRpcRequest {
const TYPE_NAME: &'static str = "SessionRpcRequest";
fn from_json(value: &Value) -> Result<SessionRpcRequest> {
let mut f = Fields::new(value, "SessionRpcRequest")?;
let request_id = DomainRequestId::read(&mut f, "requestId")?;
let scope = Scope::nullable_from_json(f.value("scope")?)?;
let params = f.object("params")?.clone();
f.finish()?;
let r = SessionRpcRequest {
request_id,
scope,
params: Value::Object(params),
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("requestId", self.request_id.to_json()),
("scope", Scope::nullable_to_json(self.scope.as_ref())),
("params", self.params.clone()),
])
}
fn validate(&self) -> Result<()> {
if !self.params.is_object() {
return err("SessionRpcRequest: params must be an object");
}
if let Some(scope) = &self.scope {
scope.validate()?;
}
canonical::reject_bus_identities(&self.params)
}
}
/// `SessionRpcSuccess`: a terminal domain success, echoing the request scope.
#[derive(Clone, Debug, PartialEq)]
pub struct SessionRpcSuccess {
pub request_id: DomainRequestId,
pub worker_id: String,
pub incarnation_id: String,
pub scope: Option<Scope>,
pub result: Value,
}
impl DomainType for SessionRpcSuccess {
const TYPE_NAME: &'static str = "SessionRpcSuccess";
fn from_json(value: &Value) -> Result<SessionRpcSuccess> {
let mut f = Fields::new(value, "SessionRpcSuccess")?;
constant(&mut f, "type", "result")?;
let request_id = DomainRequestId::read(&mut f, "requestId")?;
let worker_id = f.id("workerId")?;
let incarnation_id = f.id("incarnationId")?;
let scope = Scope::nullable_from_json(f.value("scope")?)?;
let result = f.object("result")?.clone();
f.finish()?;
let s = SessionRpcSuccess {
request_id,
worker_id,
incarnation_id,
scope,
result: Value::Object(result),
};
s.validate()?;
Ok(s)
}
fn to_json(&self) -> Value {
obj(vec![
("type", "result".into()),
("requestId", self.request_id.to_json()),
("workerId", self.worker_id.clone().into()),
("incarnationId", self.incarnation_id.clone().into()),
("scope", Scope::nullable_to_json(self.scope.as_ref())),
("result", self.result.clone()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) {
return err("SessionRpcSuccess: workerId and incarnationId must be valid ids");
}
if !self.result.is_object() {
return err("SessionRpcSuccess: result must be an object");
}
if let Some(scope) = &self.scope {
scope.validate()?;
}
Ok(())
}
}
/// `SessionRpcFailure`: a terminal domain error with an explicit mutation certainty.
#[derive(Clone, Debug, PartialEq)]
pub struct SessionRpcFailure {
pub request_id: DomainRequestId,
pub worker_id: String,
pub incarnation_id: String,
pub scope: Option<Scope>,
pub code: ErrorCode,
pub message: String,
pub mutation: MutationCertainty,
}
impl DomainType for SessionRpcFailure {
const TYPE_NAME: &'static str = "SessionRpcFailure";
fn from_json(value: &Value) -> Result<SessionRpcFailure> {
let mut f = Fields::new(value, "SessionRpcFailure")?;
constant(&mut f, "type", "error")?;
let request_id = DomainRequestId::read(&mut f, "requestId")?;
let worker_id = f.id("workerId")?;
let incarnation_id = f.id("incarnationId")?;
let scope = Scope::nullable_from_json(f.value("scope")?)?;
let (code, message, mutation) = {
let v = f.value("error")?;
let mut e = Fields::new(v, "SessionRpcFailure.error")?;
let code = ErrorCode::parse(&enumeration(&mut e, "code", ErrorCode::ALL)?)?;
let message = bounded_string(&mut e, "message", MAX_MESSAGE_CODE_POINTS)?;
let mutation = MutationCertainty::parse(&enumeration(
&mut e,
"mutation",
MutationCertainty::ALL,
)?)?;
e.finish()?;
(code, message, mutation)
};
f.finish()?;
let failure = SessionRpcFailure {
request_id,
worker_id,
incarnation_id,
scope,
code,
message,
mutation,
};
failure.validate()?;
Ok(failure)
}
fn to_json(&self) -> Value {
obj(vec![
("type", "error".into()),
("requestId", self.request_id.to_json()),
("workerId", self.worker_id.clone().into()),
("incarnationId", self.incarnation_id.clone().into()),
("scope", Scope::nullable_to_json(self.scope.as_ref())),
(
"error",
obj(vec![
("code", self.code.as_str().into()),
("message", self.message.clone().into()),
("mutation", self.mutation.as_str().into()),
]),
),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) {
return err("SessionRpcFailure: workerId and incarnationId must be valid ids");
}
if self.message.chars().count() > MAX_MESSAGE_CODE_POINTS {
return err("SessionRpcFailure: message is at most 512 code points");
}
if self.code.is_before_mutation() && self.mutation != MutationCertainty::None {
return err(format!(
"SessionRpcFailure: {} is raised before mutation, so mutation is \"none\"",
self.code.as_str()
));
}
if let Some(scope) = &self.scope {
scope.validate()?;
}
Ok(())
}
}
/// A terminal domain outcome: success or failure.
#[derive(Clone, Debug, PartialEq)]
pub enum SessionRpcOutcome {
Success(SessionRpcSuccess),
Failure(SessionRpcFailure),
}
impl SessionRpcOutcome {
pub fn request_id(&self) -> &DomainRequestId {
match self {
SessionRpcOutcome::Success(s) => &s.request_id,
SessionRpcOutcome::Failure(f) => &f.request_id,
}
}
/// Replies echo the original scope (ipc-v1 section 3).
pub fn echoes(&self, request: &SessionRpcRequest) -> bool {
let scope = match self {
SessionRpcOutcome::Success(s) => &s.scope,
SessionRpcOutcome::Failure(f) => &f.scope,
};
self.request_id() == &request.request_id && scope == &request.scope
}
}
impl DomainType for SessionRpcOutcome {
const TYPE_NAME: &'static str = "SessionRpcOutcome";
fn from_json(value: &Value) -> Result<SessionRpcOutcome> {
let kind = value
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| crate::scalar::wire_err("SessionRpcOutcome: missing type"))?;
match kind {
"result" => SessionRpcSuccess::from_json(value).map(SessionRpcOutcome::Success),
"error" => SessionRpcFailure::from_json(value).map(SessionRpcOutcome::Failure),
_ => err("SessionRpcOutcome: type must be \"result\" or \"error\""),
}
}
fn to_json(&self) -> Value {
match self {
SessionRpcOutcome::Success(s) => s.to_json(),
SessionRpcOutcome::Failure(f) => f.to_json(),
}
}
fn validate(&self) -> Result<()> {
match self {
SessionRpcOutcome::Success(s) => s.validate(),
SessionRpcOutcome::Failure(f) => f.validate(),
}
}
}

View file

@ -0,0 +1,725 @@
//! The domain scalars of ipc-v1 section 2, and the four identities that must never be confused.
//!
//! `Id`, `U64` and `Digest` are the bus encodings: this module calls straight into
//! [`flybus::wire`] instead of restating the regular expressions, and
//! `tests/encodings.rs` pins that the two agree. Everything else here is domain-only:
//! `Scope`, `RationalNs` (reduced, positive denominator, zero as `0/1`, checked arithmetic),
//! `SchemaRef` and `TypedValue` with its 32-KiB canonical-JSON cap.
use std::cmp::Ordering;
use flybus::wire::{self, Fields, WireError};
use serde_json::{Map, Value};
use crate::canonical;
pub type Result<T> = std::result::Result<T, WireError>;
/// Every parsed domain type re-validates itself, so a value built in Rust and a value read
/// from JSON are held to the same rules.
pub trait DomainType: Sized {
/// The name this type has in the canonical schema set.
const TYPE_NAME: &'static str;
/// Reads and validates one JSON value. Unknown fields are refused.
fn from_json(value: &Value) -> Result<Self>;
/// The canonical JSON shape of this value.
fn to_json(&self) -> Value;
/// The rules that are not expressible as one field read: ranges that depend on another
/// field, uniqueness, ordering and size caps.
fn validate(&self) -> Result<()>;
}
pub fn err<T>(message: impl Into<String>) -> Result<T> {
Err(WireError(message.into()))
}
pub fn wire_err(message: impl Into<String>) -> WireError {
WireError(message.into())
}
pub(crate) fn obj(pairs: Vec<(&str, Value)>) -> Value {
let mut map = Map::new();
for (key, value) in pairs {
map.insert(key.to_owned(), value);
}
Value::Object(map)
}
/// A `U64` field: the decimal string encoding, never a JSON number.
pub fn u64_json(n: u64) -> Value {
Value::String(n.to_string())
}
/// `Id`: `^[a-z0-9][a-z0-9._-]{0,63}$`, exactly the bus encoding.
pub fn is_id(s: &str) -> bool {
wire::is_id(s)
}
/// `Digest`: 64 lowercase hexadecimal digits, exactly the bus encoding.
pub fn is_digest(s: &str) -> bool {
wire::is_digest(s)
}
/// `U64`: `"0"` or `[1-9][0-9]*` up to `u64::MAX`, exactly the bus encoding.
pub fn parse_u64(s: &str) -> Option<u64> {
wire::parse_u64(s)
}
// ---------------------------------------------------------------------------------------------
// Field readers the bus reader does not have
/// A finite JSON number. NaN and infinities never survive strict parsing; this also refuses
/// integers outside the exactly representable double range, which canonical JSON cannot encode.
pub fn finite(f: &mut Fields<'_>, key: &'static str) -> Result<f64> {
let value = f.value(key)?;
match value {
Value::Number(n) => canonical::finite_double(n)
.ok_or_else(|| wire_err(format!("{key} must be a finite JSON number"))),
_ => err(format!("{key} must be a finite JSON number")),
}
}
/// A finite JSON number inside `lo..=hi`, refused rather than clamped.
pub fn finite_in(f: &mut Fields<'_>, key: &'static str, lo: f64, hi: f64) -> Result<f64> {
let n = finite(f, key)?;
if n < lo || n > hi {
return err(format!("{key} must be in [{lo}, {hi}]"));
}
Ok(n)
}
/// A JSON integer in `i32` range, the seed encoding Agent.Initialize uses.
pub fn i32_field(f: &mut Fields<'_>, key: &'static str) -> Result<i32> {
let value = f.value(key)?;
match value.as_i64() {
Some(n) if i64::from(i32::MIN) <= n && n <= i64::from(i32::MAX) => Ok(n as i32),
_ => err(format!("{key} must be a signed 32-bit integer")),
}
}
/// One member of a closed string enum.
pub fn enumeration(f: &mut Fields<'_>, key: &'static str, allowed: &[&str]) -> Result<String> {
let s = f.string(key)?;
if allowed.contains(&s) {
Ok(s.to_owned())
} else {
err(format!("{key} must be one of {}", allowed.join(", ")))
}
}
/// A string constant: a field whose only legal value is `expected`.
pub fn constant(f: &mut Fields<'_>, key: &'static str, expected: &str) -> Result<()> {
let s = f.string(key)?;
if s == expected {
Ok(())
} else {
err(format!("{key} must be {expected:?}"))
}
}
/// A `true` constant.
pub fn constant_true(f: &mut Fields<'_>, key: &'static str) -> Result<()> {
if f.boolean(key)? {
Ok(())
} else {
err(format!("{key} must be true"))
}
}
/// A string of at most `max` Unicode code points.
pub fn bounded_string(f: &mut Fields<'_>, key: &'static str, max: usize) -> Result<String> {
let s = f.string(key)?;
if s.chars().count() > max {
return err(format!("{key} must be at most {max} code points"));
}
Ok(s.to_owned())
}
/// `null`, or a string of at most `max` code points.
pub fn nullable_bounded_string(
f: &mut Fields<'_>,
key: &'static str,
max: usize,
) -> Result<Option<String>> {
match f.value(key)? {
Value::Null => Ok(None),
_ => bounded_string(f, key, max).map(Some),
}
}
/// Reads an array of `lo..=hi` items through `read`, keeping the supplied order.
pub fn list<T>(
f: &mut Fields<'_>,
key: &'static str,
lo: usize,
hi: usize,
read: impl Fn(&Value) -> Result<T>,
) -> Result<Vec<T>> {
let items = f.array(key, lo, hi)?;
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(read(item).map_err(|e| wire_err(format!("{key}: {e}")))?);
}
Ok(out)
}
/// An array of `lo..=hi` `Id`s.
pub fn id_list(f: &mut Fields<'_>, key: &'static str, lo: usize, hi: usize) -> Result<Vec<String>> {
list(f, key, lo, hi, |v| match v.as_str() {
Some(s) if is_id(s) => Ok(s.to_owned()),
_ => err("every entry must be an id"),
})
}
/// Fails on the first repeated key, naming it.
pub fn require_unique<'a>(keys: impl IntoIterator<Item = &'a str>, what: &str) -> Result<()> {
let mut seen: Vec<&str> = Vec::new();
for key in keys {
if seen.contains(&key) {
return err(format!("{what}: duplicate {key:?}"));
}
seen.push(key);
}
Ok(())
}
/// Fails unless `actual` is exactly `expected`, in that order: descriptor order is part of
/// the contract, not a set membership test.
pub fn require_same_order<'a>(
actual: impl IntoIterator<Item = &'a str>,
expected: impl IntoIterator<Item = &'a str>,
what: &str,
) -> Result<()> {
let actual: Vec<&str> = actual.into_iter().collect();
let expected: Vec<&str> = expected.into_iter().collect();
if actual != expected {
return err(format!(
"{what}: must list [{}] in that order, found [{}]",
expected.join(", "),
actual.join(", ")
));
}
Ok(())
}
// ---------------------------------------------------------------------------------------------
// Scope
/// `Scope`: the simulation timeline identity. Never the bus route or store incarnation.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Scope {
pub session_id: String,
pub epoch: String,
pub step: u64,
}
impl Scope {
pub fn new(session_id: &str, epoch: &str, step: u64) -> Result<Scope> {
let scope = Scope {
session_id: session_id.to_owned(),
epoch: epoch.to_owned(),
step,
};
scope.validate()?;
Ok(scope)
}
/// `null`, or a scope.
pub fn nullable_from_json(value: &Value) -> Result<Option<Scope>> {
match value {
Value::Null => Ok(None),
_ => Scope::from_json(value).map(Some),
}
}
pub fn nullable_to_json(scope: Option<&Scope>) -> Value {
scope.map_or(Value::Null, Scope::to_json)
}
}
impl DomainType for Scope {
const TYPE_NAME: &'static str = "Scope";
fn from_json(value: &Value) -> Result<Scope> {
let mut f = Fields::new(value, "Scope")?;
let session_id = f.id("sessionId")?;
let epoch = f.id("epoch")?;
let step = f.u64_string("step")?;
f.finish()?;
let scope = Scope {
session_id,
epoch,
step,
};
scope.validate()?;
Ok(scope)
}
fn to_json(&self) -> Value {
obj(vec![
("sessionId", self.session_id.clone().into()),
("epoch", self.epoch.clone().into()),
("step", u64_json(self.step)),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.session_id) {
return err("Scope: sessionId is not a valid id");
}
if !is_id(&self.epoch) {
return err("Scope: epoch is not a valid id");
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// RationalNs
/// A nanosecond rational: reduced, positive denominator, zero encoded `0/1`.
///
/// ipc-v1 section 2: "Fractions are reduced, denominators positive, durations positive; zero
/// is encoded 0/1. Arithmetic is checked." Durations are checked with
/// [`RationalNs::require_positive`] by the fields that are durations; `worldTime` and a tick
/// remainder are legitimately zero.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RationalNs {
pub numerator: u64,
pub denominator: u64,
}
fn gcd(a: u64, b: u64) -> u64 {
let (mut a, mut b) = (a, b);
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
fn gcd128(a: u128, b: u128) -> u128 {
let (mut a, mut b) = (a, b);
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
impl RationalNs {
pub const ZERO: RationalNs = RationalNs {
numerator: 0,
denominator: 1,
};
/// Exactly the supplied pair, which must already be in canonical form.
pub fn new(numerator: u64, denominator: u64) -> Result<RationalNs> {
let r = RationalNs {
numerator,
denominator,
};
r.validate()?;
Ok(r)
}
/// Reduces first, then validates: the constructor for arithmetic results.
pub fn reduced(numerator: u128, denominator: u128) -> Result<RationalNs> {
if denominator == 0 {
return err("RationalNs: denominator must be positive");
}
let (n, d) = if numerator == 0 {
(0u128, 1u128)
} else {
let g = gcd128(numerator, denominator);
(numerator / g, denominator / g)
};
if n > u128::from(u64::MAX) || d > u128::from(u64::MAX) {
return err("RationalNs: reduced value does not fit U64");
}
RationalNs::new(n as u64, d as u64)
}
pub fn is_zero(&self) -> bool {
self.numerator == 0
}
/// Durations must be positive (ipc-v1 section 2).
pub fn require_positive(&self, what: &str) -> Result<()> {
if self.is_zero() {
return err(format!("{what}: duration must be positive"));
}
Ok(())
}
pub fn checked_add(&self, other: &RationalNs) -> Result<RationalNs> {
let n = u128::from(self.numerator) * u128::from(other.denominator)
+ u128::from(other.numerator) * u128::from(self.denominator);
let d = u128::from(self.denominator) * u128::from(other.denominator);
RationalNs::reduced(n, d)
}
pub fn checked_sub(&self, other: &RationalNs) -> Result<RationalNs> {
let left = u128::from(self.numerator) * u128::from(other.denominator);
let right = u128::from(other.numerator) * u128::from(self.denominator);
if right > left {
return err("RationalNs: subtraction would be negative");
}
let d = u128::from(self.denominator) * u128::from(other.denominator);
RationalNs::reduced(left - right, d)
}
pub fn checked_mul_u64(&self, k: u64) -> Result<RationalNs> {
let n = u128::from(self.numerator)
.checked_mul(u128::from(k))
.ok_or_else(|| wire_err("RationalNs: multiplication overflowed"))?;
RationalNs::reduced(n, u128::from(self.denominator))
}
/// The step-v1 section 5 accumulator: `ticks = floor(self / tick)` and the remainder
/// `self - ticks * tick`, which is always `>= 0` and `< tick`.
pub fn divide_floor(&self, tick: &RationalNs) -> Result<(u64, RationalNs)> {
tick.require_positive("RationalNs::divide_floor tick")?;
let n = u128::from(self.numerator) * u128::from(tick.denominator);
let d = u128::from(self.denominator) * u128::from(tick.numerator);
let ticks = n / d;
if ticks > u128::from(u64::MAX) {
return err("RationalNs: tick count does not fit U64");
}
let ticks = ticks as u64;
let remainder = self.checked_sub(&tick.checked_mul_u64(ticks)?)?;
Ok((ticks, remainder))
}
}
impl PartialOrd for RationalNs {
fn partial_cmp(&self, other: &RationalNs) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RationalNs {
fn cmp(&self, other: &RationalNs) -> Ordering {
let left = u128::from(self.numerator) * u128::from(other.denominator);
let right = u128::from(other.numerator) * u128::from(self.denominator);
left.cmp(&right)
}
}
impl DomainType for RationalNs {
const TYPE_NAME: &'static str = "RationalNs";
fn from_json(value: &Value) -> Result<RationalNs> {
let mut f = Fields::new(value, "RationalNs")?;
let numerator = f.u64_string("numerator")?;
let denominator = f.u64_string("denominator")?;
f.finish()?;
RationalNs::new(numerator, denominator)
}
fn to_json(&self) -> Value {
obj(vec![
("numerator", u64_json(self.numerator)),
("denominator", u64_json(self.denominator)),
])
}
fn validate(&self) -> Result<()> {
if self.denominator == 0 {
return err("RationalNs: denominator must be positive");
}
if self.numerator == 0 && self.denominator != 1 {
return err("RationalNs: zero is encoded 0/1");
}
if self.numerator != 0 && gcd(self.numerator, self.denominator) != 1 {
return err("RationalNs: fraction must be reduced");
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// SchemaRef and TypedValue
/// `SchemaRef`: the identity of a registered typed payload schema. Version is 1..=65535.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SchemaRef {
pub id: String,
pub version: u16,
pub digest: String,
}
impl SchemaRef {
pub fn new(id: &str, version: u16, digest: &str) -> Result<SchemaRef> {
let r = SchemaRef {
id: id.to_owned(),
version,
digest: digest.to_owned(),
};
r.validate()?;
Ok(r)
}
}
impl DomainType for SchemaRef {
const TYPE_NAME: &'static str = "SchemaRef";
fn from_json(value: &Value) -> Result<SchemaRef> {
let mut f = Fields::new(value, "SchemaRef")?;
let id = f.id("id")?;
let version = f.int("version", 1, 65_535)? as u16;
let digest = f.string("digest")?.to_owned();
f.finish()?;
let r = SchemaRef {
id,
version,
digest,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("id", self.id.clone().into()),
("version", Value::from(u64::from(self.version))),
("digest", self.digest.clone().into()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.id) {
return err("SchemaRef: id is not a valid id");
}
if self.version == 0 {
return err("SchemaRef: version must be 1..=65535");
}
if !is_digest(&self.digest) {
return err("SchemaRef: digest must be 64 lowercase hex digits");
}
Ok(())
}
}
/// The canonical-JSON size limit of one `TypedValue` (ipc-v1 section 2, workers-v1 section 1).
pub const MAX_TYPED_VALUE_BYTES: usize = 32 * 1024;
/// `TypedValue`: a schema identity plus an object, capped at 32 KiB of canonical JSON.
#[derive(Clone, Debug, PartialEq)]
pub struct TypedValue {
pub schema: SchemaRef,
pub value: Value,
}
impl TypedValue {
pub fn new(schema: SchemaRef, value: Value) -> Result<TypedValue> {
let t = TypedValue { schema, value };
t.validate()?;
Ok(t)
}
pub fn nullable_from_json(value: &Value) -> Result<Option<TypedValue>> {
match value {
Value::Null => Ok(None),
_ => TypedValue::from_json(value).map(Some),
}
}
pub fn nullable_to_json(value: Option<&TypedValue>) -> Value {
value.map_or(Value::Null, TypedValue::to_json)
}
/// The canonical JSON byte length of the whole typed value.
pub fn canonical_len(&self) -> Result<usize> {
canonical::canonicalize(&self.to_json()).map(|s| s.len())
}
}
impl DomainType for TypedValue {
const TYPE_NAME: &'static str = "TypedValue";
fn from_json(value: &Value) -> Result<TypedValue> {
let mut f = Fields::new(value, "TypedValue")?;
let schema = SchemaRef::from_json(f.value("schema")?)?;
let inner = f.value("value")?.clone();
f.finish()?;
let t = TypedValue {
schema,
value: inner,
};
t.validate()?;
Ok(t)
}
fn to_json(&self) -> Value {
obj(vec![
("schema", self.schema.to_json()),
("value", self.value.clone()),
])
}
fn validate(&self) -> Result<()> {
self.schema.validate()?;
if !self.value.is_object() {
return err("TypedValue: value must be an object");
}
let len = self.canonical_len()?;
if len > MAX_TYPED_VALUE_BYTES {
return err(format!(
"TypedValue: {len} bytes of canonical JSON exceeds the {MAX_TYPED_VALUE_BYTES}-byte limit"
));
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// The four identities
/// A bus RPC correlation id, `call-<U64>` (bus-v1 section 6). It is not a domain operation id:
/// a safe domain retry keeps its [`DomainRequestId`] and gets a new `BusCallId`.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BusCallId(String);
/// A domain operation id, `req-` plus a canonical `U64` serial (ipc-v1 section 5).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DomainRequestId(String);
/// The identity of an immutable artifact: store incarnation, artifact id and generation.
/// Not an address, not authority to read, and not an [`crate::workers::AssetRef`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ArtifactIdentity {
pub store_id: String,
pub artifact_id: String,
pub generation: u64,
}
/// Which kind of ownership root a token names.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum OwnerKind {
/// One recipient's delivery, `dlv-<U64>`.
Delivery,
/// An explicit artifact hold, `own-<U64>`.
Hold,
}
/// A delivery or explicit-hold owner token. Connection-private: it never appears in a domain
/// payload or a canonical body digest.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct OwnerToken {
token: String,
kind: OwnerKind,
}
macro_rules! serial_identity {
($type:ty, $prefix:literal, $what:literal) => {
impl $type {
/// Parses the canonical `prefix-<U64>` form; any other prefix is refused, which is
/// what keeps the four identities from being swapped for one another.
pub fn parse(s: &str) -> Result<Self> {
match wire::parse_serial_id($prefix, s) {
Some(_) => Ok(Self(s.to_owned())),
None => err(concat!($what, " must be canonical ", $prefix, "-<U64>")),
}
}
pub fn from_serial(serial: u64) -> Self {
Self(wire::serial_id($prefix, serial))
}
pub fn serial(&self) -> u64 {
wire::parse_serial_id($prefix, &self.0).expect("validated on construction")
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn read(f: &mut Fields<'_>, key: &'static str) -> Result<Self> {
let s = f.string(key)?;
Self::parse(s).map_err(|e| wire_err(format!("{key}: {e}")))
}
pub fn read_nullable(f: &mut Fields<'_>, key: &'static str) -> Result<Option<Self>> {
match f.value(key)? {
Value::Null => Ok(None),
_ => Self::read(f, key).map(Some),
}
}
pub fn to_json(&self) -> Value {
Value::String(self.0.clone())
}
}
};
}
serial_identity!(BusCallId, "call", "a bus callId");
serial_identity!(DomainRequestId, "req", "a domain requestId");
impl OwnerToken {
pub fn parse(s: &str) -> Result<OwnerToken> {
if wire::parse_serial_id("dlv", s).is_some() {
return Ok(OwnerToken {
token: s.to_owned(),
kind: OwnerKind::Delivery,
});
}
if wire::parse_serial_id("own", s).is_some() {
return Ok(OwnerToken {
token: s.to_owned(),
kind: OwnerKind::Hold,
});
}
err("an owner token must be canonical dlv-<U64> or own-<U64>")
}
pub fn delivery(serial: u64) -> OwnerToken {
OwnerToken {
token: wire::serial_id("dlv", serial),
kind: OwnerKind::Delivery,
}
}
pub fn hold(serial: u64) -> OwnerToken {
OwnerToken {
token: wire::serial_id("own", serial),
kind: OwnerKind::Hold,
}
}
pub fn kind(&self) -> OwnerKind {
self.kind
}
pub fn as_str(&self) -> &str {
&self.token
}
}
impl ArtifactIdentity {
/// The identity half of a bus `ArtifactRef`: the parts that name the bytes, without the
/// byte length, content type or optional digest.
pub fn of(reference: &flybus::wire::ArtifactRef) -> ArtifactIdentity {
ArtifactIdentity {
store_id: reference.store_id.clone(),
artifact_id: reference.artifact_id.clone(),
generation: reference.generation,
}
}
pub fn validate(&self) -> Result<()> {
if !is_id(&self.store_id) {
return err("ArtifactIdentity: storeId is not a valid id");
}
if !is_id(&self.artifact_id) {
return err("ArtifactIdentity: artifactId is not a valid id");
}
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
//! `seed-derivation-v1`: independent per-agent seeds from one recorded master seed.
//!
//! The specification is `docs/design/session-framework/seed-derivation-v1.md`; this is its
//! reference implementation, and `fixtures/seed-vectors.json` its test vectors, which the
//! TypeScript package reproduces.
use sha2::{Digest as _, Sha256};
use crate::canonical;
use crate::scalar::{Result, err, is_id};
/// The algorithm identity. It is part of composition identity: changing any byte of the
/// derivation requires a new id.
pub const ALGORITHM: &str = "seed-derivation-v1";
/// The domain separation prefix hashed before the inputs.
pub const PREFIX: &str = "flybrain/seed-derivation-v1";
/// The SHA-256 of the derivation material for one agent, lowercase hex.
pub fn material_digest(master_seed: u64, agent_id: &str) -> Result<String> {
Ok(canonical::sha256_hex(&material(master_seed, agent_id)?))
}
/// The exact bytes hashed: the prefix, the master seed as a canonical `U64` decimal string and
/// the agent id, each followed by one `\n`.
pub fn material(master_seed: u64, agent_id: &str) -> Result<Vec<u8>> {
if !is_id(agent_id) {
return err("seed derivation: agentId is not a valid id");
}
Ok(format!("{PREFIX}\n{master_seed}\n{agent_id}\n").into_bytes())
}
/// The signed 32-bit seed `Agent.Initialize` takes for `agent_id`.
///
/// The digest is read as eight big-endian `u32` lanes; the first nonzero lane becomes the
/// seed, reinterpreted as two's-complement `i32`. Skipping zero lanes keeps the seed usable
/// by an xorshift generator, whose state must not be zero. If every lane were zero the
/// material is rehashed with a counter suffix, which no observed input has needed.
pub fn agent_seed(master_seed: u64, agent_id: &str) -> Result<i32> {
let mut material = material(master_seed, agent_id)?;
for round in 0u32..4 {
if round > 0 {
material.extend_from_slice(format!("{round}\n").as_bytes());
}
let digest = Sha256::digest(&material);
for lane in digest.chunks_exact(4) {
let word = u32::from_be_bytes([lane[0], lane[1], lane[2], lane[3]]);
if word != 0 {
return Ok(word as i32);
}
}
}
err("seed derivation: every lane of four digests was zero")
}
/// The seeds of a whole composition, in the order the agent ids are given.
///
/// Equal ids deliberately derive equal seeds: "Identical explicit seeds are allowed only when
/// the experiment intentionally declares them" (workers-v1 section 2), so a composition with a
/// repeated agent id is refused here rather than silently sharing a seed.
pub fn composition_seeds(master_seed: u64, agent_ids: &[String]) -> Result<Vec<i32>> {
crate::scalar::require_unique(
agent_ids.iter().map(String::as_str),
"seed derivation: agentIds",
)?;
agent_ids
.iter()
.map(|id| agent_seed(master_seed, id))
.collect()
}

View file

@ -0,0 +1,486 @@
//! The trace format of step-v1 section 8, split into behaviour and operational metadata.
//!
//! Section 8 requires a record, for every transition, of the scope, the Prepare request ids,
//! the agent/profile ids, tick counts and remainders, decision digests, the complete batch id
//! and control digest, the acknowledged world boundary, observation producing boundaries, task
//! event/outcome ids in order, every Commit acknowledgment and the published boundary. It then
//! requires that sequential, concurrent and reversed runs "match, excluding wall time, request
//! ids and other explicitly operational metadata".
//!
//! So this record has two halves. [`TraceBehaviour`] is what must match: it is ordered by
//! agent id rather than by completion order, so a reversed dispatch produces an identical
//! value. [`TraceOperational`] is what section 8 requires recording but excludes from the
//! comparison: wall time, the domain request ids, the bus callIds and the delivery ids.
//! [`TransitionTrace::behaviour_equals`] compares only the first half, and
//! [`TransitionTrace::behaviour_diff`] names the fields that differ.
use flybus::wire::Fields;
use serde_json::Value;
use crate::canonical;
use crate::scalar::{
BusCallId, DomainRequestId, DomainType, OwnerToken, RationalNs, Result, Scope, err, is_digest,
is_id, list, obj, require_unique, u64_json,
};
use crate::workers::{MAX_AGENTS, MAX_RATE_ROLES};
/// One agent's behaviour in one transition.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceAgent {
pub agent_id: String,
pub profile_digest: String,
pub ticks_advanced: u64,
pub brain_ticks: u64,
pub remainder: RationalNs,
pub decision_digest: String,
/// The boundary this agent acknowledged in its Commit reply.
pub committed_step: u64,
}
/// One view's producing boundary, as observed in this transition.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceObservation {
pub view_id: String,
pub produced_step: u64,
}
/// The fields two runs of the same transition must agree on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceBehaviour {
pub scope: Scope,
/// Sorted by agent id, never by completion order.
pub agents: Vec<TraceAgent>,
pub batch_id: String,
pub control_digest: String,
pub acknowledged_boundary: u64,
/// Sorted by view id.
pub observation_boundaries: Vec<TraceObservation>,
/// Task outcome ids in task order.
pub outcome_ids: Vec<String>,
/// Task event ids in task order.
pub event_ids: Vec<String>,
pub published_boundary: u64,
}
impl TraceBehaviour {
/// Sorts the order-free collections, so a trace recorded in completion order compares
/// equal to one recorded in dispatch order.
pub fn normalized(&self) -> TraceBehaviour {
let mut out = self.clone();
out.agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
out.observation_boundaries
.sort_by(|a, b| a.view_id.cmp(&b.view_id));
out
}
pub fn digest(&self) -> Result<String> {
canonical::digest_of(&self.normalized().to_json())
}
}
impl DomainType for TraceBehaviour {
const TYPE_NAME: &'static str = "TraceBehaviour";
fn from_json(value: &Value) -> Result<TraceBehaviour> {
let mut f = Fields::new(value, "TraceBehaviour")?;
let scope = Scope::from_json(f.value("scope")?)?;
let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| {
let mut a = Fields::new(v, "TraceBehaviour.agents")?;
let agent_id = a.id("agentId")?;
let profile_digest = a.string("profileDigest")?.to_owned();
let ticks_advanced = a.u64_string("ticksAdvanced")?;
let brain_ticks = a.u64_string("brainTicks")?;
let remainder = RationalNs::from_json(a.value("remainder")?)?;
let decision_digest = a.string("decisionDigest")?.to_owned();
let committed_step = a.u64_string("committedStep")?;
a.finish()?;
Ok(TraceAgent {
agent_id,
profile_digest,
ticks_advanced,
brain_ticks,
remainder,
decision_digest,
committed_step,
})
})?;
let batch_id = f.id("batchId")?;
let control_digest = f.string("controlDigest")?.to_owned();
let acknowledged_boundary = f.u64_string("acknowledgedBoundary")?;
let observation_boundaries = list(
&mut f,
"observationBoundaries",
0,
crate::media::MAX_VIEWS * 2,
|v| {
let mut o = Fields::new(v, "TraceBehaviour.observationBoundaries")?;
let view_id = o.id("viewId")?;
let produced_step = o.u64_string("producedStep")?;
o.finish()?;
Ok(TraceObservation {
view_id,
produced_step,
})
},
)?;
let outcome_ids = crate::scalar::id_list(&mut f, "outcomeIds", 0, MAX_RATE_ROLES)?;
let event_ids = crate::scalar::id_list(&mut f, "eventIds", 0, MAX_RATE_ROLES)?;
let published_boundary = f.u64_string("publishedBoundary")?;
f.finish()?;
let b = TraceBehaviour {
scope,
agents,
batch_id,
control_digest,
acknowledged_boundary,
observation_boundaries,
outcome_ids,
event_ids,
published_boundary,
};
b.validate()?;
Ok(b)
}
fn to_json(&self) -> Value {
obj(vec![
("scope", self.scope.to_json()),
(
"agents",
Value::Array(
self.agents
.iter()
.map(|a| {
obj(vec![
("agentId", a.agent_id.clone().into()),
("profileDigest", a.profile_digest.clone().into()),
("ticksAdvanced", u64_json(a.ticks_advanced)),
("brainTicks", u64_json(a.brain_ticks)),
("remainder", a.remainder.to_json()),
("decisionDigest", a.decision_digest.clone().into()),
("committedStep", u64_json(a.committed_step)),
])
})
.collect(),
),
),
("batchId", self.batch_id.clone().into()),
("controlDigest", self.control_digest.clone().into()),
("acknowledgedBoundary", u64_json(self.acknowledged_boundary)),
(
"observationBoundaries",
Value::Array(
self.observation_boundaries
.iter()
.map(|o| {
obj(vec![
("viewId", o.view_id.clone().into()),
("producedStep", u64_json(o.produced_step)),
])
})
.collect(),
),
),
(
"outcomeIds",
Value::Array(self.outcome_ids.iter().map(|i| i.clone().into()).collect()),
),
(
"eventIds",
Value::Array(self.event_ids.iter().map(|i| i.clone().into()).collect()),
),
("publishedBoundary", u64_json(self.published_boundary)),
])
}
fn validate(&self) -> Result<()> {
self.scope.validate()?;
if self.agents.is_empty() || self.agents.len() > MAX_AGENTS {
return err("TraceBehaviour: 1..=4 agents");
}
require_unique(
self.agents.iter().map(|a| a.agent_id.as_str()),
"TraceBehaviour.agents",
)?;
for agent in &self.agents {
if !is_id(&agent.agent_id) {
return err("TraceBehaviour: agentId is not a valid id");
}
if !is_digest(&agent.profile_digest) || !is_digest(&agent.decision_digest) {
return err("TraceBehaviour: agent digests must be 64 lowercase hex digits");
}
agent.remainder.validate()?;
if agent.committed_step != self.scope.step + 1 {
return err(
"TraceBehaviour: every commit acknowledgment is the transition's next boundary",
);
}
}
if !is_id(&self.batch_id) {
return err("TraceBehaviour: batchId is not a valid id");
}
if !is_digest(&self.control_digest) {
return err("TraceBehaviour: controlDigest must be 64 lowercase hex digits");
}
if self.acknowledged_boundary != self.scope.step + 1 {
return err("TraceBehaviour: the acknowledged boundary is scope.step + 1");
}
if self.published_boundary != self.acknowledged_boundary {
return err(
"TraceBehaviour: the published boundary is the boundary every agent committed",
);
}
require_unique(
self.observation_boundaries
.iter()
.map(|o| o.view_id.as_str()),
"TraceBehaviour.observationBoundaries",
)?;
require_unique(
self.event_ids.iter().map(String::as_str),
"TraceBehaviour.eventIds",
)?;
require_unique(
self.outcome_ids.iter().map(String::as_str),
"TraceBehaviour.outcomeIds",
)?;
Ok(())
}
}
/// One agent's domain request id for one phase.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceRequest {
pub agent_id: String,
pub request_id: DomainRequestId,
}
/// What step-v1 section 8 records but excludes from the comparison.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceOperational {
/// Wall time is for pacing, health and presentation only (step-v1 section 5).
pub wall_time_ns: u64,
pub prepare_request_ids: Vec<TraceRequest>,
pub advance_request_id: DomainRequestId,
pub commit_request_ids: Vec<TraceRequest>,
/// The transport correlation ids this transition happened to use. A safe retry changes
/// these and nothing in [`TraceBehaviour`].
pub bus_call_ids: Vec<BusCallId>,
pub delivery_ids: Vec<OwnerToken>,
}
impl DomainType for TraceOperational {
const TYPE_NAME: &'static str = "TraceOperational";
fn from_json(value: &Value) -> Result<TraceOperational> {
let mut f = Fields::new(value, "TraceOperational")?;
let wall_time_ns = f.u64_string("wallTimeNs")?;
let read_requests = |v: &Value| -> Result<TraceRequest> {
let mut r = Fields::new(v, "TraceOperational request")?;
let agent_id = r.id("agentId")?;
let request_id = DomainRequestId::read(&mut r, "requestId")?;
r.finish()?;
Ok(TraceRequest {
agent_id,
request_id,
})
};
let prepare_request_ids = list(&mut f, "prepareRequestIds", 1, MAX_AGENTS, read_requests)?;
let advance_request_id = DomainRequestId::read(&mut f, "advanceRequestId")?;
let commit_request_ids = list(&mut f, "commitRequestIds", 1, MAX_AGENTS, read_requests)?;
let bus_call_ids = list(&mut f, "busCallIds", 0, 64, |v| match v.as_str() {
Some(s) => BusCallId::parse(s),
None => err("every busCallId must be a string"),
})?;
let delivery_ids = list(&mut f, "deliveryIds", 0, 64, |v| match v.as_str() {
Some(s) => OwnerToken::parse(s),
None => err("every deliveryId must be a string"),
})?;
f.finish()?;
let o = TraceOperational {
wall_time_ns,
prepare_request_ids,
advance_request_id,
commit_request_ids,
bus_call_ids,
delivery_ids,
};
o.validate()?;
Ok(o)
}
fn to_json(&self) -> Value {
let requests = |items: &[TraceRequest]| {
Value::Array(
items
.iter()
.map(|r| {
obj(vec![
("agentId", r.agent_id.clone().into()),
("requestId", r.request_id.to_json()),
])
})
.collect(),
)
};
obj(vec![
("wallTimeNs", u64_json(self.wall_time_ns)),
("prepareRequestIds", requests(&self.prepare_request_ids)),
("advanceRequestId", self.advance_request_id.to_json()),
("commitRequestIds", requests(&self.commit_request_ids)),
(
"busCallIds",
Value::Array(self.bus_call_ids.iter().map(BusCallId::to_json).collect()),
),
(
"deliveryIds",
Value::Array(
self.delivery_ids
.iter()
.map(|t| Value::String(t.as_str().to_owned()))
.collect(),
),
),
])
}
fn validate(&self) -> Result<()> {
require_unique(
self.prepare_request_ids.iter().map(|r| r.agent_id.as_str()),
"TraceOperational.prepareRequestIds",
)?;
require_unique(
self.commit_request_ids.iter().map(|r| r.agent_id.as_str()),
"TraceOperational.commitRequestIds",
)?;
require_unique(
self.bus_call_ids.iter().map(BusCallId::as_str),
"TraceOperational.busCallIds",
)?;
require_unique(
self.delivery_ids.iter().map(OwnerToken::as_str),
"TraceOperational.deliveryIds",
)?;
Ok(())
}
}
/// One transition's trace: behaviour plus operational metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransitionTrace {
pub behaviour: TraceBehaviour,
pub operational: TraceOperational,
}
impl TransitionTrace {
/// Behaviour equality: the comparison step-v1 section 8 asks for.
pub fn behaviour_equals(&self, other: &TransitionTrace) -> bool {
self.behaviour.normalized() == other.behaviour.normalized()
}
/// The behaviour fields that differ, named. Empty when [`Self::behaviour_equals`] holds.
pub fn behaviour_diff(&self, other: &TransitionTrace) -> Vec<String> {
let (a, b) = (self.behaviour.normalized(), other.behaviour.normalized());
let mut out = Vec::new();
if a.scope != b.scope {
out.push(format!("scope: {:?} vs {:?}", a.scope, b.scope));
}
if a.batch_id != b.batch_id {
out.push(format!("batchId: {} vs {}", a.batch_id, b.batch_id));
}
if a.control_digest != b.control_digest {
out.push("controlDigest differs".to_owned());
}
if a.acknowledged_boundary != b.acknowledged_boundary {
out.push(format!(
"acknowledgedBoundary: {} vs {}",
a.acknowledged_boundary, b.acknowledged_boundary
));
}
if a.published_boundary != b.published_boundary {
out.push(format!(
"publishedBoundary: {} vs {}",
a.published_boundary, b.published_boundary
));
}
if a.observation_boundaries != b.observation_boundaries {
out.push("observationBoundaries differ".to_owned());
}
if a.outcome_ids != b.outcome_ids {
out.push("outcomeIds differ".to_owned());
}
if a.event_ids != b.event_ids {
out.push("eventIds differ".to_owned());
}
let ids_a: Vec<&str> = a.agents.iter().map(|x| x.agent_id.as_str()).collect();
let ids_b: Vec<&str> = b.agents.iter().map(|x| x.agent_id.as_str()).collect();
if ids_a != ids_b {
out.push(format!(
"agents: [{}] vs [{}]",
ids_a.join(", "),
ids_b.join(", ")
));
} else {
for (left, right) in a.agents.iter().zip(&b.agents) {
if left != right {
out.push(format!("agent {}: behaviour differs", left.agent_id));
}
}
}
out
}
/// Two whole runs agree on behaviour, transition by transition.
pub fn runs_equal(left: &[TransitionTrace], right: &[TransitionTrace]) -> bool {
left.len() == right.len() && left.iter().zip(right).all(|(a, b)| a.behaviour_equals(b))
}
}
impl DomainType for TransitionTrace {
const TYPE_NAME: &'static str = "TransitionTrace";
fn from_json(value: &Value) -> Result<TransitionTrace> {
let mut f = Fields::new(value, "TransitionTrace")?;
let behaviour = TraceBehaviour::from_json(f.value("behaviour")?)?;
let operational = TraceOperational::from_json(f.value("operational")?)?;
f.finish()?;
let t = TransitionTrace {
behaviour,
operational,
};
t.validate()?;
Ok(t)
}
fn to_json(&self) -> Value {
obj(vec![
("behaviour", self.behaviour.to_json()),
("operational", self.operational.to_json()),
])
}
fn validate(&self) -> Result<()> {
self.behaviour.validate()?;
self.operational.validate()?;
let behaviour_agents: Vec<&str> = self
.behaviour
.agents
.iter()
.map(|a| a.agent_id.as_str())
.collect();
for phase in [
&self.operational.prepare_request_ids,
&self.operational.commit_request_ids,
] {
for request in phase {
if !behaviour_agents.contains(&request.agent_id.as_str()) {
return err(format!(
"TransitionTrace: request recorded for {:?}, which is not in the transition",
request.agent_id
));
}
}
}
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,192 @@
//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5.
use fly_session_types::scalar::{DomainType, Scope};
use fly_session_types::{canonical, fixtures};
use serde_json::{Value, json};
#[test]
fn object_keys_are_sorted_by_utf16_code_unit() {
let value = json!({"b": 1, "a": 2, "A": 3, "\u{00e9}": 4, "\u{10400}": 5, "\u{ff21}": 6});
assert_eq!(
canonical::canonicalize(&value).expect("canonicalizable"),
"{\"A\":3,\"a\":2,\"b\":1,\"\u{00e9}\":4,\"\u{10400}\":5,\"\u{ff21}\":6}",
"keys sort by UTF-16 code unit, so an astral key (leading surrogate D801) sorts \
before U+FF21, which is where a JavaScript string sort puts it too and where a sort \
by Unicode code point would not"
);
}
#[test]
fn numbers_print_the_way_ecmascript_prints_them() {
let file = fixtures::load("boundaries.json").expect("boundaries.json");
for case in file.get("doubles").and_then(Value::as_array).expect("doubles") {
let value = case.get("value").expect("value");
let accept = case.get("accept").and_then(Value::as_bool).expect("accept");
let outcome = canonical::canonicalize(value);
assert_eq!(
outcome.is_ok(),
accept,
"{value}: {}",
fixtures::field(case, "reason").unwrap_or("")
);
if let (Ok(text), Some(expected)) = (outcome, case.get("canonical").and_then(Value::as_str))
{
assert_eq!(text, expected, "{value} must print as {expected}");
}
}
}
#[test]
fn strings_are_escaped_the_way_json_stringify_escapes_them() {
let value = json!({"s": "quote \" backslash \\ tab \t newline \n bell \u{7} del \u{7f} e\u{301}"});
assert_eq!(
canonical::canonicalize(&value).expect("canonicalizable"),
"{\"s\":\"quote \\\" backslash \\\\ tab \\t newline \\n bell \\u0007 del \u{7f} e\u{301}\"}",
"only the escapes JSON.stringify emits, with lowercase hex"
);
}
#[test]
fn canonical_form_does_not_depend_on_the_input_formatting() {
let compact = br#"{"b":[1,2,{"y":true,"x":null}],"a":"z"}"#;
let pretty = br#"{
"a" : "z",
"b": [ 1, 2, { "x": null, "y": true } ]
}"#;
let left = canonical::parse_strict(compact).expect("parses");
let right = canonical::parse_strict(pretty).expect("parses");
assert_eq!(
canonical::canonicalize(&left).expect("canonicalizable"),
canonical::canonicalize(&right).expect("canonicalizable")
);
assert_eq!(
canonical::digest_of(&left).expect("digest"),
canonical::digest_of(&right).expect("digest"),
"whitespace and key order are not content"
);
}
#[test]
fn duplicate_keys_and_invalid_utf8_never_parse() {
assert!(canonical::parse_strict(br#"{"a":1,"a":2}"#).is_err());
assert!(canonical::parse_strict(b"{\"a\":\"\xff\"}").is_err());
assert!(canonical::parse_strict(br#"{"a":1} {"b":2}"#).is_err());
assert!(canonical::parse_strict(br#"{"a":NaN}"#).is_err());
}
#[test]
fn an_envelope_over_64_kib_is_refused() {
let big = json!({"pad": "a".repeat(canonical::MAX_ENVELOPE_BYTES)});
assert!(canonical::require_envelope_fit(&big, 0).is_err());
let small = json!({"pad": "a"});
let length = canonical::canonicalize(&small).expect("canonicalizable").len();
assert_eq!(
canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length)
.expect("fits exactly"),
canonical::MAX_ENVELOPE_BYTES
);
assert!(
canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length + 1)
.is_err(),
"one byte past the ceiling is refused"
);
}
#[test]
fn operation_keys_match_the_fixture_and_separate_the_operations_they_should() {
let file = fixtures::load("operations.json").expect("operations.json");
let mut digests: Vec<(String, String)> = Vec::new();
for case in file.get("keys").and_then(Value::as_array).expect("keys") {
let name = fixtures::field(case, "name").expect("name");
let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope");
let method = fixtures::field(case, "method").expect("method");
let worker = fixtures::field(case, "workerId").expect("workerId");
let key = canonical::OperationKey::new(scope, method, worker).expect("a key");
let digest = key.digest().expect("digest");
assert_eq!(
digest,
fixtures::field(case, "digest").expect("digest"),
"{name}: operation key digest must match the fixture"
);
digests.push((name.to_owned(), digest));
}
for (index, (name, digest)) in digests.iter().enumerate() {
for (other_name, other) in &digests[index + 1..] {
assert_ne!(
digest, other,
"{name} and {other_name} are different operations"
);
}
}
}
#[test]
fn canonical_bodies_match_the_fixture() {
let file = fixtures::load("operations.json").expect("operations.json");
for case in file.get("bodies").and_then(Value::as_array).expect("bodies") {
let name = fixtures::field(case, "name").expect("name");
let method = fixtures::field(case, "method").expect("method");
let scope = match case.get("scope") {
Some(Value::Null) | None => None,
Some(v) => Some(Scope::from_json(v).expect("scope")),
};
let params = case.get("params").expect("params");
assert_eq!(
canonical::body_digest(method, scope.as_ref(), params).expect("digest"),
fixtures::field(case, "digest").expect("digest"),
"{name}: canonical body digest must match the fixture"
);
}
}
/// The pairs that decide whether a duplicate is a safe replay or a CONFLICT.
#[test]
fn operation_pairs_agree_with_the_fixture_about_sameness() {
let file = fixtures::load("operations.json").expect("operations.json");
for case in file.get("pairs").and_then(Value::as_array).expect("pairs") {
let name = fixtures::field(case, "name").expect("name");
let reason = fixtures::field(case, "reason").expect("reason");
let worker = fixtures::field(case, "workerId").expect("workerId");
let right_worker = fixtures::field(case, "rightWorkerId").unwrap_or(worker);
let side = |key: &str, worker: &str| {
let value = case.get(key).expect("side");
let method = fixtures::field(value, "method").expect("method");
let scope = Scope::from_json(value.get("scope").expect("scope")).expect("scope");
let params = value.get("params").expect("params");
let key_digest = canonical::OperationKey::new(scope.clone(), method, worker)
.expect("a key")
.digest()
.expect("digest");
let body = canonical::body_digest(method, Some(&scope), params).expect("digest");
(key_digest, body)
};
let (left_key, left_body) = side("left", worker);
let (right_key, right_body) = side("right", right_worker);
assert_eq!(
left_key == right_key,
case.get("sameKey").and_then(Value::as_bool).expect("sameKey"),
"{name}: operation key sameness. {reason}"
);
assert_eq!(
left_body == right_body,
case.get("sameBody").and_then(Value::as_bool).expect("sameBody"),
"{name}: canonical body sameness. {reason}"
);
}
}
#[test]
fn a_domain_body_can_never_carry_a_bus_identity() {
let file = fixtures::load("operations.json").expect("operations.json");
for case in file.get("rejected").and_then(Value::as_array).expect("rejected") {
let name = fixtures::field(case, "name").expect("name");
let method = fixtures::field(case, "method").expect("method");
let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope");
let params = case.get("params").expect("params");
assert!(
canonical::body_digest(method, Some(&scope), params).is_err(),
"{name} must be refused: {}",
fixtures::field(case, "reason").unwrap_or("")
);
}
}

View file

@ -0,0 +1,165 @@
//! The `FLYSESS1` envelope layout: its fixture, its offsets and the corruptions it refuses.
use fly_session_types::{canonical, checkpoint, fixtures};
use serde_json::Value;
fn envelope_bytes(file: &Value) -> Vec<u8> {
fixtures::decode_base64(
file.get("envelope")
.and_then(|e| e.get("base64"))
.and_then(Value::as_str)
.expect("base64"),
)
.expect("base64")
}
#[test]
fn the_fixture_envelope_decodes_to_its_recorded_layout() {
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
let bytes = envelope_bytes(&file);
let envelope = checkpoint::decode(&bytes).expect("a valid envelope");
checkpoint::validate_manifest(&envelope).expect("a complete manifest");
let layout = file["envelope"]["layout"].clone();
assert_eq!(&bytes[0..8], checkpoint::MAGIC);
assert_eq!(
bytes.len().to_string(),
layout["totalBytes"].as_str().expect("totalBytes")
);
assert_eq!(
envelope.layout.table_offset.to_string(),
layout["tableOffset"].as_str().expect("tableOffset")
);
assert_eq!(
envelope.layout.manifest_bytes,
layout["manifestBytes"].as_u64().expect("manifestBytes") as u32
);
let entries = layout["entries"].as_array().expect("entries");
assert_eq!(envelope.layout.entries.len(), entries.len());
for (entry, recorded) in envelope.layout.entries.iter().zip(entries) {
assert_eq!(entry.name, recorded["name"].as_str().expect("name"));
assert_eq!(
entry.offset.to_string(),
recorded["offset"].as_str().expect("offset")
);
assert_eq!(
entry.byte_length.to_string(),
recorded["byteLength"].as_str().expect("byteLength")
);
assert_eq!(
checkpoint::hex(&entry.digest),
recorded["digest"].as_str().expect("digest")
);
assert_eq!(entry.offset % 8, 0, "payloads start on an eight-byte boundary");
}
for payload in file["payloads"].as_array().expect("payloads") {
let name = payload["name"].as_str().expect("name");
let expected = fixtures::decode_base64(payload["base64"].as_str().expect("base64"))
.expect("base64");
assert_eq!(
envelope.payload(name).expect("a payload"),
expected.as_slice(),
"payload {name} must come back byte for byte"
);
}
assert_eq!(
envelope.manifest,
file["manifest"],
"the manifest round trips as canonical JSON"
);
}
#[test]
fn every_recorded_corruption_is_refused() {
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
let bytes = envelope_bytes(&file);
for case in file["corruption"].as_array().expect("corruption") {
let name = case["name"].as_str().expect("name");
let offset = case["offset"].as_u64().expect("offset") as usize;
let mut corrupted = bytes.clone();
corrupted[offset] ^= 0x01;
assert!(
checkpoint::decode(&corrupted).is_err(),
"{name} must be refused: {}",
case["reason"].as_str().unwrap_or("")
);
}
let truncated = &bytes[..bytes.len() - 1];
assert!(
checkpoint::decode(truncated).is_err(),
"a truncated envelope must be refused"
);
assert!(
checkpoint::decode(&bytes[..8]).is_err(),
"a header alone is not an envelope"
);
}
#[test]
fn a_flysim01_envelope_is_not_read_as_a_session_checkpoint() {
// The historical envelope: magic, u32 manifest length, manifest, chunks, CRC32.
let mut legacy = Vec::new();
legacy.extend_from_slice(b"FLYSIM01");
let manifest = br#"{"schemaVersion":2,"chunks":[]}"#;
legacy.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
legacy.extend_from_slice(manifest);
legacy.extend_from_slice(&0u32.to_le_bytes());
assert!(
checkpoint::decode(&legacy).is_err(),
"FLYSESS1 is a new format; the old reader stays separate"
);
}
#[test]
fn the_layout_is_deterministic_and_the_manifest_is_canonical() {
let manifest = canonical::parse_strict(br#"{"b":2,"a":1}"#).expect("parses");
let payloads = vec![
("one".to_owned(), b"first".to_vec()),
("two".to_owned(), vec![0u8; 9]),
];
let bytes = checkpoint::encode(&manifest, &payloads).expect("encode");
let again = checkpoint::encode(&manifest, &payloads).expect("encode");
assert_eq!(bytes, again, "the same inputs produce the same bytes");
let envelope = checkpoint::decode(&bytes).expect("decode");
let start = checkpoint::HEADER_BYTES;
let end = start + envelope.layout.manifest_bytes as usize;
assert_eq!(
std::str::from_utf8(&bytes[start..end]).expect("utf-8"),
"{\"a\":1,\"b\":2}",
"the manifest is stored as canonical JSON"
);
assert_eq!(envelope.layout.entries[1].offset % 8, 0);
assert!(
checkpoint::encode(
&manifest,
&[("one".to_owned(), vec![]), ("one".to_owned(), vec![])]
)
.is_err(),
"payload names are unique"
);
assert!(
checkpoint::encode(&manifest, &[("One".to_owned(), vec![])]).is_err(),
"payload names are Ids, and the widening from letters-only is deliberate, not arbitrary"
);
assert!(
checkpoint::encode(&manifest, &[("a".to_owned(), Vec::new())]).is_ok(),
"an empty payload is still a payload"
);
}
#[test]
fn a_manifest_missing_a_required_field_is_not_a_complete_checkpoint() {
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
let full = file["manifest"].clone();
for field in checkpoint::REQUIRED_MANIFEST_FIELDS {
let mut manifest = full.clone();
manifest.as_object_mut().expect("object").remove(*field);
let bytes = checkpoint::encode(&manifest, &[]).expect("encode");
let envelope = checkpoint::decode(&bytes).expect("decode");
assert!(
checkpoint::validate_manifest(&envelope).is_err(),
"a manifest without {field:?} must be refused"
);
}
}

View file

@ -0,0 +1,133 @@
//! One place that knows how to read every type named by a fixture.
use flybus::wire::WireError;
use fly_session_types::media::*;
use fly_session_types::publishing::*;
use fly_session_types::rpc::*;
use fly_session_types::scalar::*;
use fly_session_types::trace::*;
use fly_session_types::workers::*;
use serde_json::Value;
/// Reads the value as `type_name`, re-runs its validate step and writes it back out.
///
/// Every fixture assertion goes through this, so a type that reads a field but forgets to
/// write it back fails the round trip.
pub fn round_trip(type_name: &str, value: &Value) -> std::result::Result<Value, WireError> {
macro_rules! arm {
($t:ty) => {
if type_name == <$t as DomainType>::TYPE_NAME {
let parsed = <$t as DomainType>::from_json(value)?;
parsed.validate()?;
return Ok(parsed.to_json());
}
};
}
arm!(Scope);
arm!(RationalNs);
arm!(SchemaRef);
arm!(TypedValue);
arm!(SessionRpcRequest);
arm!(SessionRpcSuccess);
arm!(SessionRpcFailure);
arm!(AssetRef);
arm!(SensoryInput);
arm!(Stimulus);
arm!(Reward);
arm!(AgentTelemetry);
arm!(AgentInitializeParams);
arm!(AgentInitializeResult);
arm!(PrepareParams);
arm!(PreparedDecision);
arm!(CommitParams);
arm!(AgentCommitResult);
arm!(ControllerSchema);
arm!(PortControl);
arm!(EnvironmentDescriptor);
arm!(EnvironmentInitializeParams);
arm!(EnvironmentInitializeResult);
arm!(WorldObservation);
arm!(AdvanceParams);
arm!(StepResult);
arm!(HelloParams);
arm!(HelloResult);
arm!(StatusResult);
arm!(AcknowledgeParams);
arm!(AcknowledgeResult);
arm!(ShutdownParams);
arm!(ShutdownResult);
arm!(TaskEvent);
arm!(EpisodeRequest);
arm!(ViewDescriptor);
arm!(ViewRef);
arm!(AudioDescriptor);
arm!(AudioRef);
arm!(CaptureParams);
arm!(CaptureResult);
arm!(StageRestoreParams);
arm!(StageRestoreResult);
arm!(ActivateRestoreParams);
arm!(ActivateRestoreResult);
arm!(SessionDescriptor);
arm!(CommittedSnapshot);
arm!(TraceBehaviour);
arm!(TraceOperational);
arm!(TransitionTrace);
Err(WireError(format!(
"no fixture reader for type {type_name:?}"
)))
}
/// The type names `round_trip` knows.
pub const READABLE_TYPES: &[&str] = &[
"Scope",
"RationalNs",
"SchemaRef",
"TypedValue",
"SessionRpcRequest",
"SessionRpcSuccess",
"SessionRpcFailure",
"AssetRef",
"SensoryInput",
"Stimulus",
"Reward",
"AgentTelemetry",
"AgentInitializeParams",
"AgentInitializeResult",
"PrepareParams",
"PreparedDecision",
"CommitParams",
"AgentCommitResult",
"ControllerSchema",
"PortControl",
"EnvironmentDescriptor",
"EnvironmentInitializeParams",
"EnvironmentInitializeResult",
"WorldObservation",
"AdvanceParams",
"StepResult",
"HelloParams",
"HelloResult",
"StatusResult",
"AcknowledgeParams",
"AcknowledgeResult",
"ShutdownParams",
"ShutdownResult",
"TaskEvent",
"EpisodeRequest",
"ViewDescriptor",
"ViewRef",
"AudioDescriptor",
"AudioRef",
"CaptureParams",
"CaptureResult",
"StageRestoreParams",
"StageRestoreResult",
"ActivateRestoreParams",
"ActivateRestoreResult",
"SessionDescriptor",
"CommittedSnapshot",
"TraceBehaviour",
"TraceOperational",
"TransitionTrace",
];

View file

@ -0,0 +1,78 @@
//! Rules that need a descriptor in hand: complete batches, the observation delay rule, byte
//! shapes and descriptor agreement.
use fly_session_types::fixtures;
use fly_session_types::publishing::{CommittedSnapshot, SessionDescriptor};
use fly_session_types::scalar::{DomainType, Result};
use fly_session_types::workers::{
EnvironmentDescriptor, PortControl, SensoryInput, StepResult, WorldObservation,
};
#[test]
fn every_descriptor_check_lands_the_way_the_fixture_says() {
let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json");
let descriptor =
EnvironmentDescriptor::from_json(file.get("descriptor").expect("descriptor")).expect("descriptor");
let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed"))
.expect("delayed descriptor");
let session = SessionDescriptor::from_json(file.get("sessionDescriptor").expect("session"))
.expect("session descriptor");
let previous = WorldObservation::from_json(file.get("stepResultPrevious").expect("previous"))
.expect("previous observation");
for case in fixtures::cases(&file).expect("cases") {
let name = fixtures::field(case, "name").expect("name");
let kind = fixtures::field(case, "kind").expect("kind");
let reason = fixtures::field(case, "reason").unwrap_or("");
let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept";
let value = case.get("value").expect("value");
let outcome: Result<()> = match kind {
"portControl" => PortControl::from_json(value).and_then(|control| {
match descriptor.port(&control.port_id) {
Some(port) => control.validate_against(&port.controls),
None => fly_session_types::scalar::err("no such port"),
}
}),
"advanceControls" => value
.as_array()
.expect("an array of controls")
.iter()
.map(PortControl::from_json)
.collect::<Result<Vec<_>>>()
.and_then(|controls| descriptor.validate_batch(&controls)),
"sensoryInput" => SensoryInput::from_json(value)
.and_then(|input| input.validate_against(&descriptor.views)),
"sensoryInputDelayed" => {
SensoryInput::from_json(value).and_then(|input| input.validate_against(&delayed.views))
}
"worldObservation" => WorldObservation::from_json(value)
.and_then(|observation| observation.validate_against(&descriptor)),
"stepResult" => StepResult::from_json(value)
.and_then(|result| result.validate_against(&descriptor, &previous)),
"snapshot" => CommittedSnapshot::from_json(value)
.and_then(|snapshot| snapshot.validate_against(&session)),
other => panic!("unknown descriptor check kind {other:?}"),
};
assert_eq!(
outcome.is_ok(),
expect_accept,
"{name}: expected {}. {reason}. outcome: {outcome:?}",
if expect_accept { "accept" } else { "reject" }
);
}
}
/// The delay rule itself, stated once: `max(0, boundary - observationDelaySteps)`.
#[test]
fn the_required_producing_boundary_saturates_at_zero() {
let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json");
let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed"))
.expect("delayed descriptor");
let view = &delayed.views[0];
assert_eq!(view.observation_delay_steps, 2);
assert_eq!(view.required_produced_step(0), 0);
assert_eq!(view.required_produced_step(1), 0);
assert_eq!(view.required_produced_step(2), 0);
assert_eq!(view.required_produced_step(3), 1);
assert_eq!(view.frame_bytes(), u64::from(160u32 * 4 * 144));
}

View file

@ -0,0 +1,169 @@
//! The scalar encodings agree with the bus's, and the four identities cannot be confused.
use fly_session_types::fixtures;
use fly_session_types::scalar::{
ArtifactIdentity, BusCallId, DomainRequestId, OwnerKind, OwnerToken, is_digest, is_id, parse_u64,
};
use serde_json::Value;
/// The domain `Id`, `U64` and `Digest` are the bus encodings, not a second opinion about them.
#[test]
fn domain_scalars_are_the_bus_scalars() {
let ids = [
"a",
"fly-a",
"0",
"a.b_c-d",
"",
"A",
"-a",
".a",
"a b",
"fly/a",
&"a".repeat(64),
&"a".repeat(65),
];
for id in ids {
assert_eq!(
is_id(id),
flybus::wire::is_id(id),
"Id encoding must agree with the bus for {id:?}"
);
}
let numbers = [
"0",
"1",
"18446744073709551615",
"18446744073709551616",
"01",
"",
"-1",
"1.0",
" 1",
];
for text in numbers {
assert_eq!(
parse_u64(text),
flybus::wire::parse_u64(text),
"U64 encoding must agree with the bus for {text:?}"
);
}
let digests = [
&"a".repeat(64),
&"0".repeat(64),
&"A".repeat(64),
&"g".repeat(64),
&"a".repeat(63),
];
for digest in digests {
assert_eq!(
is_digest(digest),
flybus::wire::is_digest(digest),
"Digest encoding must agree with the bus"
);
}
}
#[test]
fn u64_boundaries_reject_from_the_fixture() {
let file = fixtures::load("boundaries.json").expect("boundaries.json");
let cases = file.get("u64").and_then(Value::as_array).expect("u64");
for case in cases {
let text = fixtures::field(case, "text").expect("text");
let accept = case.get("accept").and_then(Value::as_bool).expect("accept");
assert_eq!(
parse_u64(text).is_some(),
accept,
"{text:?}: {}",
fixtures::field(case, "reason").unwrap_or("")
);
}
}
/// bus callId, domain requestId and delivery/hold owner tokens are four types. The fixture
/// says, for every spelling, which of them accept it: no string is accepted by two.
#[test]
fn the_four_identities_never_accept_each_others_spellings() {
let file = fixtures::load("identities.json").expect("identities.json");
for case in fixtures::cases(&file).expect("cases") {
let text = fixtures::field(case, "text").expect("text");
let call = case.get("busCallId").and_then(Value::as_bool).expect("busCallId");
let request = case
.get("domainRequestId")
.and_then(Value::as_bool)
.expect("domainRequestId");
let owner = case.get("ownerToken").expect("ownerToken");
assert_eq!(BusCallId::parse(text).is_ok(), call, "busCallId {text:?}");
assert_eq!(
DomainRequestId::parse(text).is_ok(),
request,
"domainRequestId {text:?}"
);
match owner {
Value::Null => assert!(
OwnerToken::parse(text).is_err(),
"owner token {text:?} must be refused"
),
Value::String(kind) => {
let parsed = OwnerToken::parse(text).expect("an owner token");
let expected = match kind.as_str() {
"delivery" => OwnerKind::Delivery,
"hold" => OwnerKind::Hold,
other => panic!("unknown owner kind {other:?}"),
};
assert_eq!(parsed.kind(), expected, "owner kind of {text:?}");
}
other => panic!("unexpected ownerToken field {other:?}"),
}
let accepted = [call, request, OwnerToken::parse(text).is_ok()]
.iter()
.filter(|a| **a)
.count();
assert!(
accepted <= 1,
"{text:?} is accepted by more than one identity type"
);
}
}
/// An artifact identity is the naming half of a bus ArtifactRef, and nothing else in these
/// contracts is one: an AssetRef is persistent installed content, not live bytes.
#[test]
fn artifact_identity_is_the_naming_half_of_an_artifact_ref() {
let file = fixtures::load("identities.json").expect("identities.json");
let section = file.get("artifact").expect("artifact");
let reference =
flybus::wire::ArtifactRef::from_json(section.get("ref").expect("ref")).expect("a ref");
let identity = ArtifactIdentity::of(&reference);
identity.validate().expect("a valid identity");
let expected = section.get("identity").expect("identity");
assert_eq!(identity.store_id, expected["storeId"].as_str().unwrap());
assert_eq!(identity.artifact_id, expected["artifactId"].as_str().unwrap());
assert_eq!(identity.generation.to_string(), expected["generation"].as_str().unwrap());
let asset = fly_session_types::workers::AssetRef::from_json(section_asset(&file)).expect("asset");
assert_ne!(
asset.id, identity.artifact_id,
"the fixture's asset and artifact are deliberately different things"
);
}
fn section_asset(file: &Value) -> &Value {
file.get("asset").expect("asset")
}
/// A delivery id or hold token is connection-private. The domain reader has no field that
/// takes one, which is what `canonical::reject_bus_identities` enforces; here we only pin
/// that the two prefixes the bus issues are the two kinds this type knows.
#[test]
fn owner_tokens_come_in_exactly_two_kinds() {
assert_eq!(OwnerToken::delivery(7).as_str(), "dlv-7");
assert_eq!(OwnerToken::hold(9).as_str(), "own-9");
assert_eq!(OwnerToken::delivery(7).kind(), OwnerKind::Delivery);
assert_eq!(OwnerToken::hold(9).kind(), OwnerKind::Hold);
assert_eq!(BusCallId::from_serial(12).as_str(), "call-12");
assert_eq!(DomainRequestId::from_serial(41).as_str(), "req-41");
assert_eq!(DomainRequestId::from_serial(41).serial(), 41);
}
use fly_session_types::scalar::DomainType as _;

View file

@ -0,0 +1,177 @@
//! The `valid.json`, `invalid.json`, `raw.json` and `generated.json` fixtures.
mod common;
use fly_session_types::scalar::{DomainType, MAX_TYPED_VALUE_BYTES, SchemaRef, TypedValue};
use fly_session_types::{canonical, fixtures};
use serde_json::{Value, json};
use common::round_trip;
#[test]
fn every_valid_case_round_trips_and_canonicalizes_to_its_recorded_bytes() {
let file = fixtures::load("valid.json").expect("valid.json");
let cases = fixtures::cases(&file).expect("cases");
for case in cases {
let name = fixtures::field(case, "name").expect("name");
let type_name = fixtures::field(case, "type").expect("type");
let value = case.get("value").expect("value");
let written = round_trip(type_name, value)
.unwrap_or_else(|e| panic!("{name} ({type_name}) must be accepted: {e}"));
let canonical_in = canonical::canonicalize(value).expect("canonicalizable");
let canonical_out = canonical::canonicalize(&written).expect("canonicalizable");
assert_eq!(
canonical_in, canonical_out,
"{name}: reading and writing must preserve every field"
);
assert_eq!(
canonical_in,
fixtures::field(case, "canonical").expect("canonical"),
"{name}: canonical JSON must match the fixture"
);
assert_eq!(
canonical::sha256_hex(canonical_in.as_bytes()),
fixtures::field(case, "digest").expect("digest"),
"{name}: digest must match the fixture"
);
}
assert!(cases.len() >= 70, "the valid fixture should stay broad");
}
#[test]
fn every_type_the_readers_know_appears_in_the_valid_fixture() {
let file = fixtures::load("valid.json").expect("valid.json");
let cases = fixtures::cases(&file).expect("cases");
let covered: Vec<&str> = cases
.iter()
.map(|case| fixtures::field(case, "type").expect("type"))
.collect();
let missing: Vec<&&str> = common::READABLE_TYPES
.iter()
.filter(|t| !covered.contains(*t))
.collect();
assert!(
missing.is_empty(),
"every readable type needs at least one accepted fixture; missing {missing:?}"
);
}
#[test]
fn every_invalid_case_is_refused() {
let file = fixtures::load("invalid.json").expect("invalid.json");
let cases = fixtures::cases(&file).expect("cases");
for case in cases {
let name = fixtures::field(case, "name").expect("name");
let type_name = fixtures::field(case, "type").expect("type");
let reason = fixtures::field(case, "reason").expect("reason");
let value = case.get("value").expect("value");
let outcome = round_trip(type_name, value);
assert!(
outcome.is_err(),
"{name} ({type_name}) must be refused: {reason}"
);
}
assert!(cases.len() >= 80, "the invalid fixture should stay broad");
}
#[test]
fn every_raw_byte_case_is_refused_before_or_during_validation() {
let file = fixtures::load("raw.json").expect("raw.json");
for case in fixtures::cases(&file).expect("cases") {
let name = fixtures::field(case, "name").expect("name");
let type_name = fixtures::field(case, "type").expect("type");
let reason = fixtures::field(case, "reason").expect("reason");
let bytes = fixtures::base64(case, "base64").unwrap_or_default();
let outcome = canonical::parse_strict(&bytes).and_then(|value| {
round_trip(type_name, &value).map_err(|e| fly_session_types::scalar::wire_err(e.0))
});
assert!(outcome.is_err(), "{name} must be refused: {reason}");
}
}
/// The recipes in `generated.json`: payloads too large to store as fixtures.
#[test]
fn generated_boundary_cases_land_on_the_right_side_of_every_limit() {
let file = fixtures::load("generated.json").expect("generated.json");
let pad_schema = SchemaRef::from_json(file.get("padSchema").expect("padSchema")).expect("schema");
for case in fixtures::cases(&file).expect("cases") {
let name = fixtures::field(case, "name").expect("name");
let kind = fixtures::field(case, "kind").expect("kind");
let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept";
let outcome: Result<(), String> = match kind {
"padded-typed-value" => {
let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize;
TypedValue::new(pad_schema.clone(), json!({"pad": "a".repeat(pad)}))
.map(|_| ())
.map_err(|e| e.0)
}
"padded-request" => {
let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize;
let total = case
.get("envelopeTotal")
.and_then(Value::as_u64)
.expect("envelopeTotal") as usize;
let request = json!({
"requestId": "req-1",
"scope": Value::Null,
"params": {"pad": "a".repeat(pad)},
});
let body = round_trip("SessionRpcRequest", &request).expect("a request");
let length = canonical::canonicalize(&body).expect("canonicalizable").len();
canonical::require_envelope_fit(&body, total - length)
.map(|_| ())
.map_err(|e| e.0)
}
"error-message" | "error-message-astral" => {
let points = case.get("codePoints").and_then(Value::as_u64).expect("codePoints")
as usize;
let character = if kind == "error-message" { 'x' } else { '\u{10400}' };
let message: String = std::iter::repeat_n(character, points).collect();
let failure = json!({
"type": "error",
"requestId": "req-41",
"workerId": "fly-a",
"incarnationId": "inc-1",
"scope": Value::Null,
"error": {"code": "INTERNAL", "message": message, "mutation": "unknown"},
});
round_trip("SessionRpcFailure", &failure)
.map(|_| ())
.map_err(|e| e.0)
}
other => panic!("unknown generated case kind {other:?}"),
};
assert_eq!(
outcome.is_ok(),
expect_accept,
"{name}: expected {}, got {outcome:?}",
if expect_accept { "accept" } else { "reject" }
);
}
}
#[test]
fn a_typed_value_at_the_cap_is_accepted_and_one_byte_more_is_not() {
let schema = SchemaRef::new("pad.v1", 1, &canonical::sha256_hex(b"pad.v1")).expect("schema");
let overhead = canonical::canonicalize(
&TypedValue::new(schema.clone(), json!({"pad": ""}))
.expect("empty")
.to_json(),
)
.expect("canonicalizable")
.len();
let at_cap = TypedValue::new(
schema.clone(),
json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead)}),
)
.expect("exactly at the cap");
assert_eq!(at_cap.canonical_len().expect("length"), MAX_TYPED_VALUE_BYTES);
assert!(
TypedValue::new(
schema,
json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead + 1)})
)
.is_err(),
"one byte over the cap must fail"
);
}

View file

@ -0,0 +1,140 @@
//! Checked rational arithmetic and the step-v1 section 5 tick accumulator.
use fly_session_types::scalar::{DomainType, RationalNs};
use fly_session_types::fixtures;
use serde_json::Value;
fn rational(value: &Value) -> RationalNs {
RationalNs::from_json(value).expect("a valid rational")
}
#[test]
fn the_accumulator_produces_the_fixture_tick_counts_and_remainders() {
let file = fixtures::load("rational.json").expect("rational.json");
for case in file
.get("accumulator")
.and_then(Value::as_array)
.expect("accumulator")
{
let name = fixtures::field(case, "name").expect("name");
let step = rational(case.get("stepDuration").expect("stepDuration"));
let tick = rational(case.get("tickDuration").expect("tickDuration"));
let mut accumulator = RationalNs::ZERO;
let mut total = 0u64;
for (index, expected) in case
.get("steps")
.and_then(Value::as_array)
.expect("steps")
.iter()
.enumerate()
{
accumulator = accumulator.checked_add(&step).expect("checked add");
let (ticks, remainder) = accumulator.divide_floor(&tick).expect("checked divide");
accumulator = remainder;
total += ticks;
assert_eq!(
ticks.to_string(),
fixtures::field(expected, "ticks").expect("ticks"),
"{name}: tick count at step {index}"
);
assert_eq!(
remainder,
rational(expected.get("remainder").expect("remainder")),
"{name}: remainder at step {index}"
);
assert!(
remainder < tick,
"{name}: the remainder is always less than one model tick"
);
}
assert_eq!(
total.to_string(),
fixtures::field(case, "totalTicks").expect("totalTicks"),
"{name}: total ticks"
);
}
}
#[test]
fn checked_arithmetic_reduces_or_refuses() {
let file = fixtures::load("rational.json").expect("rational.json");
for case in file.get("add").and_then(Value::as_array).expect("add") {
let outcome = rational(case.get("a").expect("a")).checked_add(&rational(case.get("b").expect("b")));
match case.get("sum") {
Some(sum) => assert_eq!(outcome.expect("a sum"), rational(sum)),
None => assert!(outcome.is_err(), "the sum must overflow: {case}"),
}
}
for case in file
.get("subtract")
.and_then(Value::as_array)
.expect("subtract")
{
let outcome =
rational(case.get("a").expect("a")).checked_sub(&rational(case.get("b").expect("b")));
match case.get("difference") {
Some(difference) => assert_eq!(outcome.expect("a difference"), rational(difference)),
None => assert!(outcome.is_err(), "the subtraction must fail: {case}"),
}
}
for case in file
.get("multiply")
.and_then(Value::as_array)
.expect("multiply")
{
let k: u64 = fixtures::field(case, "k")
.expect("k")
.parse()
.expect("a u64");
let outcome = rational(case.get("a").expect("a")).checked_mul_u64(k);
match case.get("product") {
Some(product) => assert_eq!(outcome.expect("a product"), rational(product)),
None => assert!(outcome.is_err(), "the product must overflow: {case}"),
}
}
for case in file
.get("compare")
.and_then(Value::as_array)
.expect("compare")
{
let left = rational(case.get("a").expect("a"));
let right = rational(case.get("b").expect("b"));
let ordering = match fixtures::field(case, "ordering").expect("ordering") {
"less" => std::cmp::Ordering::Less,
"equal" => std::cmp::Ordering::Equal,
"greater" => std::cmp::Ordering::Greater,
other => panic!("unknown ordering {other:?}"),
};
assert_eq!(left.cmp(&right), ordering, "{case}");
}
}
#[test]
fn zero_has_exactly_one_encoding_and_durations_must_be_positive() {
assert_eq!(RationalNs::ZERO, RationalNs::new(0, 1).expect("0/1"));
assert!(RationalNs::new(0, 2).is_err(), "zero is encoded 0/1");
assert!(RationalNs::new(1, 0).is_err(), "denominators are positive");
assert!(RationalNs::new(2, 4).is_err(), "fractions are reduced");
assert!(RationalNs::ZERO.require_positive("worldTime").is_err());
assert!(
RationalNs::new(1, 3)
.expect("1/3")
.require_positive("tickDuration")
.is_ok()
);
assert!(
RationalNs::ZERO.divide_floor(&RationalNs::ZERO).is_err(),
"dividing by a zero tick is refused, not infinite"
);
}
#[test]
fn reduction_refuses_a_result_that_does_not_fit_u64() {
let big = RationalNs::new(u64::MAX, 1).expect("a whole number");
assert!(big.checked_mul_u64(2).is_err());
assert!(big.checked_add(&big).is_err());
assert_eq!(
RationalNs::reduced(u128::from(u64::MAX) * 2, 2).expect("reduces back into range"),
big
);
}

View file

@ -0,0 +1,187 @@
//! The canonical schema set, `contractDigest` and the freshness of every derived fixture.
use fly_session_types::{canonical, fixtures, schema};
use serde_json::Value;
#[path = "../examples/update_fixtures.rs"]
#[allow(dead_code, reason = "the example's main is not used by the test that reuses its writers")]
mod updater;
/// Every derived fixture is exactly what the updater writes today. If this fails, run
/// `cargo run -p fly-session-types --example update_fixtures` and review the diff.
#[test]
fn derived_fixtures_are_current() {
for (name, expected) in updater::derived() {
let path = fixtures::dir().join(&name);
let found = std::fs::read_to_string(&path).expect("a checked-in fixture");
assert_eq!(
found, expected,
"{name} is stale; regenerate it with the update_fixtures example"
);
}
}
#[test]
fn the_contract_digest_is_the_digest_of_the_checked_in_schema_set() {
let text = fixtures::load_bytes("schema-set.json").expect("schema-set.json");
let recorded = fixtures::load("contract-digest.json").expect("contract-digest.json");
let expected = recorded
.get("contractDigest")
.and_then(Value::as_str)
.expect("contractDigest");
assert_eq!(schema::contract_digest(), expected);
// The file is the canonical schema set plus one trailing newline.
assert_eq!(
canonical::sha256_hex(text.strip_suffix(b"\n").expect("trailing newline")),
expected,
"the digest is over the canonical schema set, byte for byte"
);
}
/// The digest comes from the schema declaration, not from the source text: reparsing the
/// checked-in file with different whitespace and key order gives the same digest.
#[test]
fn the_contract_digest_survives_reformatting() {
let bytes = fixtures::load_bytes("schema-set.json").expect("schema-set.json");
let parsed = canonical::parse_strict(bytes.strip_suffix(b"\n").expect("newline")).expect("parses");
let pretty = serde_json::to_vec_pretty(&parsed).expect("serializable");
let reparsed = canonical::parse_strict(&pretty).expect("parses");
assert_eq!(
canonical::digest_of(&reparsed).expect("digest"),
schema::contract_digest(),
"pretty printing the schema set does not change its digest"
);
let shuffled = canonical::parse_strict(
br#"{"version":1,"contract":"fly-session-types-other"}"#,
)
.expect("parses");
assert_ne!(
canonical::digest_of(&shuffled).expect("digest"),
schema::contract_digest()
);
}
/// ... and it changes when a schema changes: a renamed field, a widened bound, one more enum
/// member or one fewer type all move the digest.
#[test]
fn the_contract_digest_changes_when_a_schema_changes() {
let baseline = schema::contract_digest();
let mutate = |mutation: fn(&mut Value)| {
let mut set = schema::schema_set();
mutation(&mut set);
canonical::digest_of(&set).expect("digest")
};
let renamed_field = mutate(|set| {
set["types"][0]["fields"][0]["name"] = Value::String("sessionIdentifier".to_owned());
});
let widened_bound = mutate(|set| {
for limit in set["limits"].as_array_mut().expect("limits") {
if limit["name"] == Value::String("maxAgents".to_owned()) {
limit["value"] = Value::from(8u64);
}
}
});
let extra_enum_member = mutate(|set| {
set["enums"][0]["members"]
.as_array_mut()
.expect("members")
.push(Value::String("s16le-interleaved".to_owned()));
});
let dropped_type = mutate(|set| {
set["types"].as_array_mut().expect("types").pop();
});
let relaxed_constraint = mutate(|set| {
set["types"][0]["fields"][0]["constraint"] = Value::String("anything".to_owned());
});
for (what, digest) in [
("a renamed field", renamed_field),
("a widened bound", widened_bound),
("an extra enum member", extra_enum_member),
("a dropped type", dropped_type),
("a relaxed constraint", relaxed_constraint),
] {
assert_ne!(digest, baseline, "{what} must change contractDigest");
}
}
#[test]
fn the_schema_set_names_every_type_the_crate_reads() {
let set = schema::schema_set();
let names: Vec<&str> = set["types"]
.as_array()
.expect("types")
.iter()
.map(|t| t["name"].as_str().expect("name"))
.collect();
for expected in [
"Scope",
"RationalNs",
"TypedValue",
"SessionRpcRequest",
"SessionRpcFailure",
"PrepareParams",
"StepResult",
"ViewRef",
"AudioRef",
"CaptureResult",
"SessionDescriptor",
"CommittedSnapshot",
"TraceBehaviour",
"TraceOperational",
] {
assert!(names.contains(&expected), "the schema set must name {expected}");
}
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "the rendered set is sorted by type name");
let mut unique = sorted.clone();
unique.dedup();
assert_eq!(unique.len(), names.len(), "no type is declared twice");
}
/// Every bound the schema set publishes is the constant the code enforces, and every bound
/// this crate chose rather than read from a document says so.
#[test]
fn published_limits_match_the_constants_and_name_their_source() {
let set = schema::schema_set();
let limits = set["limits"].as_array().expect("limits");
let find = |name: &str| -> u64 {
limits
.iter()
.find(|l| l["name"] == Value::String(name.to_owned()))
.and_then(|l| l["value"].as_u64())
.unwrap_or_else(|| panic!("the schema set must publish {name}"))
};
assert_eq!(find("maxAgents"), fly_session_types::workers::MAX_AGENTS as u64);
assert_eq!(find("maxPorts"), fly_session_types::workers::MAX_PORTS as u64);
assert_eq!(
find("maxRateRoles"),
fly_session_types::workers::MAX_RATE_ROLES as u64
);
assert_eq!(find("maxViews"), fly_session_types::media::MAX_VIEWS as u64);
assert_eq!(
find("maxTypedValueBytes"),
fly_session_types::scalar::MAX_TYPED_VALUE_BYTES as u64
);
assert_eq!(
find("maxEnvelopeBytes"),
canonical::MAX_ENVELOPE_BYTES as u64
);
let crate_chosen: Vec<&str> = limits
.iter()
.filter(|l| l["source"] == Value::String("crate".to_owned()))
.map(|l| l["name"].as_str().expect("name"))
.collect();
assert_eq!(
crate_chosen,
[
"maxAssets",
"maxAudioStreams",
"maxCapabilities",
"maxSnapshotEvents",
"maxSupportedMajors",
"maxSupportedStimuli",
],
"a bound with no stated source must be declared as this crate's choice"
);
}

View file

@ -0,0 +1,120 @@
//! `seed-derivation-v1` against its test vectors.
use fly_session_types::{fixtures, seed};
use serde_json::Value;
#[test]
fn every_vector_derives_its_recorded_seed() {
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
assert_eq!(
file.get("algorithm").and_then(Value::as_str),
Some(seed::ALGORITHM)
);
let vectors = file.get("vectors").and_then(Value::as_array).expect("vectors");
for case in vectors {
let master: u64 = fixtures::field(case, "masterSeed")
.expect("masterSeed")
.parse()
.expect("a U64");
let agent = fixtures::field(case, "agentId").expect("agentId");
assert_eq!(
String::from_utf8(seed::material(master, agent).expect("material")).expect("utf-8"),
fixtures::field(case, "material").expect("material"),
"the hashed material is part of the specification"
);
assert_eq!(
seed::material_digest(master, agent).expect("digest"),
fixtures::field(case, "materialDigest").expect("materialDigest")
);
assert_eq!(
i64::from(seed::agent_seed(master, agent).expect("seed")),
case.get("seed").and_then(Value::as_i64).expect("seed"),
"seed for {agent} under master {master}"
);
}
assert!(vectors.len() >= 20, "keep the vector table broad");
}
#[test]
fn one_composition_gets_independent_seeds() {
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
let composition = file.get("composition").expect("composition");
let master: u64 = fixtures::field(composition, "masterSeed")
.expect("masterSeed")
.parse()
.expect("a U64");
let ids: Vec<String> = composition
.get("agentIds")
.and_then(Value::as_array)
.expect("agentIds")
.iter()
.map(|v| v.as_str().expect("an id").to_owned())
.collect();
let seeds = seed::composition_seeds(master, &ids).expect("seeds");
let recorded: Vec<i64> = composition
.get("seeds")
.and_then(Value::as_array)
.expect("seeds")
.iter()
.map(|v| v.as_i64().expect("a seed"))
.collect();
assert_eq!(
seeds.iter().map(|s| i64::from(*s)).collect::<Vec<_>>(),
recorded
);
let mut unique = seeds.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), seeds.len(), "per-agent seeds are independent");
assert!(
seeds.iter().all(|s| *s != 0),
"a zero seed would stall an xorshift generator"
);
}
#[test]
fn a_different_master_seed_or_agent_id_derives_a_different_seed() {
assert_ne!(
seed::agent_seed(0, "fly-a").expect("seed"),
seed::agent_seed(1, "fly-a").expect("seed")
);
assert_ne!(
seed::agent_seed(0, "fly-a").expect("seed"),
seed::agent_seed(0, "fly-b").expect("seed")
);
assert_eq!(
seed::agent_seed(7, "fly-a").expect("seed"),
seed::agent_seed(7, "fly-a").expect("seed"),
"the derivation is a function of its recorded inputs"
);
}
#[test]
fn invalid_inputs_are_refused_rather_than_normalized() {
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
for case in file.get("invalid").and_then(Value::as_array).expect("invalid") {
let master: u64 = fixtures::field(case, "masterSeed")
.expect("masterSeed")
.parse()
.expect("a U64");
if let Ok(agent) = fixtures::field(case, "agentId") {
assert!(
seed::agent_seed(master, agent).is_err(),
"{agent:?} must be refused: {}",
fixtures::field(case, "reason").unwrap_or("")
);
} else {
let ids: Vec<String> = case
.get("agentIds")
.and_then(Value::as_array)
.expect("agentIds")
.iter()
.map(|v| v.as_str().expect("an id").to_owned())
.collect();
assert!(
seed::composition_seeds(master, &ids).is_err(),
"a repeated agent id must be refused"
);
}
}
}

View file

@ -0,0 +1,113 @@
//! The step-v1 section 8 trace comparator: behaviour only.
use fly_session_types::fixtures;
use fly_session_types::scalar::DomainType;
use fly_session_types::trace::TransitionTrace;
use serde_json::Value;
fn trace(value: &Value) -> TransitionTrace {
TransitionTrace::from_json(value).expect("a valid trace")
}
#[test]
fn every_variant_compares_the_way_the_fixture_says() {
let file = fixtures::load("traces.json").expect("traces.json");
let baseline = trace(file.get("baseline").expect("baseline"));
for case in file
.get("variants")
.and_then(Value::as_array)
.expect("variants")
{
let name = fixtures::field(case, "name").expect("name");
let variant = trace(case.get("trace").expect("trace"));
let expected = case
.get("behaviourEquals")
.and_then(Value::as_bool)
.expect("behaviourEquals");
let equal = baseline.behaviour_equals(&variant);
let diff = baseline.behaviour_diff(&variant);
assert_eq!(
equal, expected,
"{name}: behaviour equality. differences: {diff:?}"
);
assert_eq!(
diff.is_empty(),
expected,
"{name}: the diff must be empty exactly when the behaviour matches"
);
if let Ok(needle) = fixtures::field(case, "diffContains") {
assert!(
diff.iter().any(|line| line.contains(needle)),
"{name}: the diff should name {needle:?}, got {diff:?}"
);
}
if expected {
assert_eq!(
baseline.behaviour.digest().expect("digest"),
variant.behaviour.digest().expect("digest"),
"{name}: equal behaviour has one digest"
);
}
}
}
#[test]
fn a_whole_run_compares_transition_by_transition() {
let file = fixtures::load("traces.json").expect("traces.json");
let baseline = trace(file.get("baseline").expect("baseline"));
let variants = file
.get("variants")
.and_then(Value::as_array)
.expect("variants");
let reversed = trace(variants[0].get("trace").expect("trace"));
let changed = trace(
variants
.iter()
.find(|case| fixtures::field(case, "name").unwrap_or("") == "one extra neural tick")
.expect("the extra tick variant")
.get("trace")
.expect("trace"),
);
assert!(TransitionTrace::runs_equal(
&[baseline.clone(), baseline.clone()],
&[reversed, baseline.clone()]
));
assert!(!TransitionTrace::runs_equal(
std::slice::from_ref(&baseline),
&[changed]
));
let longer = [baseline.clone(), baseline.clone()];
assert!(
!TransitionTrace::runs_equal(std::slice::from_ref(&baseline), &longer),
"a run with more transitions is not the same run"
);
}
/// The operational half is recorded, and never part of the comparison.
#[test]
fn operational_metadata_is_recorded_and_excluded() {
let file = fixtures::load("traces.json").expect("traces.json");
let baseline = trace(file.get("baseline").expect("baseline"));
assert_eq!(baseline.operational.bus_call_ids.len(), 3);
assert_eq!(baseline.operational.prepare_request_ids.len(), 2);
assert_eq!(baseline.operational.delivery_ids.len(), 2);
assert!(baseline.operational.wall_time_ns > 0);
let retried = trace(
file.get("variants")
.and_then(Value::as_array)
.expect("variants")
.iter()
.find(|case| {
fixtures::field(case, "name").unwrap_or("")
== "a safe retry with fresh bus callIds, delivery ids and wall time"
})
.expect("the retry variant")
.get("trace")
.expect("trace"),
);
assert_ne!(
baseline.operational, retried.operational,
"the retry really did change the operational half"
);
assert!(baseline.behaviour_equals(&retried));
}