heimdall-consent: typed records for the answer collector #12

Merged
libby merged 8 commits from libby/consent-records into main 2026-11-07 08:28:31 +00:00
6 changed files with 1543 additions and 0 deletions

716
packages/heimdall-consent/Cargo.lock generated Normal file
View file

@ -0,0 +1,716 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cultcache-rs"
version = "0.3.1"
dependencies = [
"anyhow",
"chrono",
"cultcache-rs-derive",
"fs2",
"redb",
"rmp-serde",
"serde",
"serde_bytes",
"serde_json",
"serde_stacker",
"stacker",
"uuid",
"windows-sys 0.59.0",
]
[[package]]
name = "cultcache-rs-derive"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "heimdall-consent"
version = "0.1.0"
dependencies = [
"anyhow",
"cultcache-rs",
"serde",
"tempfile",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redb"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b"
dependencies = [
"libc",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_stacker"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
dependencies = [
"serde",
"serde_core",
"stacker",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "uuid"
version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -0,0 +1,18 @@
[package]
name = "heimdall-consent"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
[lib]
name = "heimdall_consent"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
cultcache-rs = { path = "../cultcache-rs" }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,36 @@
# heimdall-consent
Typed CultCache records for Heimdall's answer collector: `NoticeAcceptance`, `Answer` and `MemberTerms`,
plus the rules that need no store to state (`Answer::is_training_data`, `Answer::redacted`,
`validate`). Cut from `docs/answer-collector-consent.md` in GameCult/Heimdall PR #6 at doc commit
`aaeb175` (PR head); the doc's tables and Representation section are the contract, and its rulings are on the PR
(comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here.
Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs`
(`cargo test`). Planned, not live: the collector, the tally, the training export.
## Where each ruling lands (doc lines at aaeb175)
| Ruling | Doc | Crate |
|---|---|---|
| Timestamps are Unix ms UTC, signed 64-bit | L118-119; `received_at` is collector-assigned, L95, L208-209 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` |
| Ids are non-empty opaque strings, `event_id` verbatim; every version field (`notice_version`, the `@N` of a sample id, `terms_version`) is a positive integer, no leading zeros, no sign, at most 4294967295 (Heimdall ruling 2026-11-07, one rule; doc line "Version rule" under Representation, Heimdall PR #6 commit d6a8d42, now in the doc at aaeb175) | L120-136 | `check_version` is the single rule; `check_sample_id` and `MemberTerms::validate` call it; `notice_version: u32` with `validate()` refusing 0; golden vector `newcomer` carries `terms_version` 4294967295, and case `one-version-rule` pins 4294967295 accepted, 4294967296 and `01` refused for `notice_version`, the sample id `@N` and `terms_version` |
| Key parts joined with U+001F; empty part or part containing it refused | L137-138 | `key()` |
| Tombstone is the exited `Answer` with `text_hash` (32 bytes); HMAC-SHA-256 keyed by a per-member salt held by the member-side service; the collector cannot compute it | L94, L165-185, L221-226 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L181-182); `validate()` requires 32 bytes and `exited` |
| A reply with no established notice version or scope is stored with both absent, writes no acceptance, is excluded from every use | L107-114 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` |
| A reply's version is the one pinned at its `origin_server_ts`; changed version needs a fresh first reply | L19-20, L49-51, L112-114 | acceptance key includes the version; `is_training_data` accepts only the acceptance for the answer's own version; vector case `version-change` |
| `MemberTerms` is the typed shape of the member record's `terms_version`/`terms_accepted_at`, not a second store | L68-76 | `MemberTerms` doc comment; the collector does not hold it |
| Training needs acceptance `scope = train` equal to the answer's `scope`, and `accepted_at <= received_at` | L149-156 | `is_training_data` |
| One store per scope plus a store for scopeless replies | L158-163 | not modelled; the collector's layout owns it |
Rulings of comment 759 (doc L92, L98-105; no open questions remain):
| Ruling | Doc | Crate |
|---|---|---|
| A live answer must have non-empty `text` and is rejected otherwise; empty text is valid only on an exited answer (text cleared, hash-only tombstone) | L92, L103-105 | `Answer::validate()` refuses a non-exited answer whose `text` is absent or empty; an exited answer holds no text. Vector cases `empty-live-rejected`, `empty-revoked` |
| `received_at` is assigned by the collector; a tie on it breaks by `event_id` compared bytewise, the greater wins as the later reply | L95, L98-101 | `Answer::supersedes`: `(received_at, event_id bytes)` order; the vector harness replaces a stored answer only when the incoming one supersedes it. Vector cases `tie-greater-first`, `tie-greater-last` |
Not modelled (doc at aaeb175):
- The `org.gamecult.notice` room state event (L24-37, L132-136): its content is `{version, scope}` with `version` a JSON number, and a string (including `"01"`) is malformed. The crate checks the version only as decimal text (`check_version`); reading, power-level checks and the malformed-means-`show` fallback belong to the collector.
- The registration-checkbox gap (L78-79): the `m.login.terms` checkbox records no version. The doc rules nothing on it; the crate takes `terms_version` as given.

View file

@ -0,0 +1,225 @@
//! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md`
//! in GameCult/Heimdall PR #6 (doc commit aaeb175): the notice acceptance record, the answer record,
//! and the typed shape of the terms pair on the member record. Field names and types follow the
//! doc's tables and its Representation section; README.md cites the doc lines.
use anyhow::{Result, bail};
use cultcache_rs::DatabaseEntry;
use serde::{Deserialize, Serialize};
/// What a notice allows, each level including the ones below it. `Train` is declared in terms by the
/// room's notice; nothing here widens a scope.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
Show,
Study,
Train,
}
/// Separates key parts (Representation: parts joined with U+001F; an empty part or one containing it
/// is refused, not escaped).
const SEP: char = '\u{1f}';
fn key(parts: &[&str]) -> Result<String> {
if let Some(p) = parts.iter().find(|p| p.is_empty() || p.contains(SEP)) {
bail!("key part {p:?} is empty or contains the key separator");
}
Ok(parts.join(&SEP.to_string()))
}
fn nonempty(name: &str, value: &str) -> Result<()> {
if value.is_empty() {
bail!("{name} must be a non-empty string");
}
Ok(())
}
/// The one version rule (Heimdall ruling 2026-11-07): a positive integer written without leading
/// zeros or a sign, at most 4294967295 (`u32::MAX`). `01` and `1` would be two keys for one version.
pub fn check_version(version: &str) -> Result<u32> {
match version.parse::<u32>() {
Ok(v) if v > 0 && !version.starts_with(['+', '0']) => Ok(v),
_ => bail!("version {version:?} must be a positive integer, no leading zeros, at most {}", u32::MAX),
}
}
/// `<sample>@<version>`, the version under `check_version`. The version is part of the id, so it is
/// part of every answer key and no key can name a sample without one.
pub fn check_sample_id(sample_id: &str) -> Result<(&str, u32)> {
let Some((sample, version)) = sample_id.rsplit_once('@') else {
bail!("sample id {sample_id:?} has no @version");
};
if sample.is_empty() {
bail!("sample id {sample_id:?} needs a non-empty sample");
}
Ok((sample, check_version(version)?))
}
/// The first reply a member posts under a notice version. Written once, never updated.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "heimdall.notice-acceptance", schema = "HeimdallNoticeAcceptance")]
pub struct NoticeAcceptance {
#[cultcache(key = 0)]
pub member_id: String,
#[cultcache(key = 1)]
pub experiment_id: String,
#[cultcache(key = 2)]
pub notice_version: u32,
#[cultcache(key = 3)]
pub scope: Scope,
/// `origin_server_ts` of the first reply, unix milliseconds UTC.
#[cultcache(key = 4)]
pub accepted_at: i64,
#[cultcache(key = 5)]
pub event_id: String,
}
impl NoticeAcceptance {
/// Representation: ids are non-empty strings, versions positive.
pub fn validate(&self) -> Result<()> {
nonempty("member_id", &self.member_id)?;
nonempty("experiment_id", &self.experiment_id)?;
nonempty("event_id", &self.event_id)?;
if self.notice_version == 0 {
bail!("notice_version must be positive");
}
Ok(())
}
/// `(experiment_id, member_id, notice_version)`.
pub fn cache_key(experiment_id: &str, member_id: &str, notice_version: u32) -> Result<String> {
key(&[experiment_id, member_id, &notice_version.to_string()])
}
}
/// One answer. `notice_version` and `scope` are absent together on a reply with no established
/// notice version or no matching acceptance: such a reply is stored, excluded from every use, and
/// writes no acceptance (Answer record, "A reply whose `notice_version` or `scope` is missing").
/// Once exited the record is the tombstone: `text` absent, `text_hash` set.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "heimdall.answer", schema = "HeimdallAnswer")]
pub struct Answer {
#[cultcache(key = 0)]
pub sample_id: String,
#[cultcache(key = 1)]
pub experiment_id: String,
#[cultcache(key = 2)]
pub member_id: String,
#[cultcache(key = 3, default)]
pub notice_version: Option<u32>,
#[cultcache(key = 4, default)]
pub scope: Option<Scope>,
/// Absent once exited.
#[cultcache(key = 5, default)]
pub text: Option<String>,
/// One-way: set by redaction, never cleared.
#[cultcache(key = 6, default)]
pub exited: bool,
/// Assigned by the collector on receipt, unix milliseconds UTC, not copied from the reply (doc L95);
/// ties are broken by `supersedes`.
#[cultcache(key = 7)]
pub received_at: i64,
#[cultcache(key = 8)]
pub event_id: String,
/// The tombstone hash of `text`: HMAC-SHA-256, 32 raw bytes, computed by the member-side service
/// (it holds the salt). Present only once exited, and only if the collector held `text`.
#[cultcache(key = 9, default)]
pub text_hash: Option<Vec<u8>>,
}
impl Answer {
/// `(experiment_id, sample_id, member_id)`; refuses a sample id without a positive version.
pub fn cache_key(experiment_id: &str, sample_id: &str, member_id: &str) -> Result<String> {
check_sample_id(sample_id)?;
key(&[experiment_id, sample_id, member_id])
}
/// Representation and the invariants the table implies.
pub fn validate(&self) -> Result<()> {
nonempty("member_id", &self.member_id)?;
nonempty("experiment_id", &self.experiment_id)?;
nonempty("event_id", &self.event_id)?;
check_sample_id(&self.sample_id)?;
if self.notice_version == Some(0) {
bail!("notice_version must be positive");
}
if self.notice_version.is_some() != self.scope.is_some() {
bail!("notice_version and scope are present together or absent together");
}
if self.exited && self.text.is_some() {
bail!("an exited answer holds no text");
}
if !self.exited && self.text.as_deref().is_none_or(str::is_empty) {
bail!("a live answer needs non-empty text; empty text is valid only on a revoked answer");
}
if let Some(hash) = &self.text_hash {
if !self.exited || hash.len() != 32 {
bail!("text_hash is 32 bytes and only on an exited answer");
}
}
Ok(())
}
/// Replacement rule for two answers at one key: the later reply is the greater `(received_at,
/// event_id)`. `received_at` is assigned by the collector; a tie breaks by `event_id` compared
/// bytewise, the greater winning (Heimdall PR #6 comment 759). Insertion order never decides.
pub fn supersedes(&self, earlier: &Answer) -> bool {
(self.received_at, self.event_id.as_bytes()) > (earlier.received_at, earlier.event_id.as_bytes())
}
/// Redaction is exit: text erased, `exited` set, the tombstone hash kept. The hash comes from the
/// member-side service, which holds the salt; this crate never computes it. A reply the collector
/// never collected has no text and gets no tombstone, so an answer without `text` is refused.
pub fn redacted(&self, text_hash: [u8; 32]) -> Result<Answer> {
if self.text.is_none() {
bail!("no text held for event {:?}: nothing to tombstone", self.event_id);
}
Ok(Answer { text: None, exited: true, text_hash: Some(text_hash.to_vec()), ..self.clone() })
}
/// Training rule (doc "Training rule"): scope `train`; an acceptance for this answer's
/// `(experiment_id, member_id, notice_version)` whose own scope is `train` and equals the answer's,
/// with `accepted_at <= received_at`; not exited. A changed notice version needs its own acceptance,
/// so an acceptance for another version never covers the answer. No recorded scope, version or
/// acceptance means excluded.
pub fn is_training_data(&self, acceptance: Option<&NoticeAcceptance>) -> bool {
let (Some(Scope::Train), Some(version), Some(acc)) = (self.scope, self.notice_version, acceptance) else {
return false;
};
!self.exited
&& acc.notice_version == version
&& acc.experiment_id == self.experiment_id
&& acc.member_id == self.member_id
&& acc.scope == Scope::Train
&& Some(acc.scope) == self.scope
&& acc.accepted_at <= self.received_at
}
}
/// The typed shape of the `terms_version` / `terms_accepted_at` pair on the Heimdall member record
/// (newcomers only, recorded by the `m.login.terms` stage). It is not a second store and the
/// collector does not hold it. Members who joined before the stage, and Minds, have no such pair.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "heimdall.member-terms", schema = "HeimdallMemberTerms")]
pub struct MemberTerms {
#[cultcache(key = 0)]
pub member_id: String,
#[cultcache(key = 1)]
pub terms_version: String,
/// Unix milliseconds UTC.
#[cultcache(key = 2)]
pub terms_accepted_at: i64,
}
impl MemberTerms {
pub fn validate(&self) -> Result<()> {
nonempty("member_id", &self.member_id)?;
check_version(&self.terms_version).map(drop)
}
/// `member_id`.
pub fn cache_key(member_id: &str) -> Result<String> {
key(&[member_id])
}
}

View file

@ -0,0 +1,470 @@
use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore};
use heimdall_consent::*;
const VECTORS: &str = include_str!("vectors/consent_golden.csv");
fn new_cache(dir: &tempfile::TempDir) -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<NoticeAcceptance>().unwrap();
cache.register_entry_type::<Answer>().unwrap();
cache.register_entry_type::<MemberTerms>().unwrap();
let _ = cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("consent.cc")));
cache.pull_all_backing_stores().unwrap();
cache
}
fn scope(s: &str) -> Option<Scope> {
match s {
"" => None,
"show" => Some(Scope::Show),
"study" => Some(Scope::Study),
"train" => Some(Scope::Train),
other => panic!("unknown scope {other}"),
}
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn put_answer(cache: &mut CultCache, a: &Answer) {
cache.put(Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap(), a).unwrap();
}
/// The collector's replace rule: an incoming answer replaces the stored one only if it is the later reply.
fn put_if_later(cache: &mut CultCache, a: &Answer) {
let k = Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap();
if cache.get::<Answer>(&k).unwrap().is_some_and(|old| !a.supersedes(&old)) {
return;
}
cache.put(k, a).unwrap();
}
fn training_events(cache: &CultCache) -> Vec<String> {
let mut ids: Vec<String> = cache
.get_all::<Answer>()
.unwrap()
.iter()
.filter(|a| {
let acc = a.notice_version.and_then(|v| {
cache.get::<NoticeAcceptance>(&NoticeAcceptance::cache_key(&a.experiment_id, &a.member_id, v).unwrap()).unwrap()
});
a.is_training_data(acc.as_ref())
})
.map(|a| a.event_id.clone())
.collect();
ids.sort();
ids
}
#[test]
fn golden_vectors() {
let mut cases: Vec<(&str, Vec<Vec<&str>>)> = Vec::new();
for line in VECTORS.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').collect();
assert_eq!(f.len(), 10, "row `{line}`");
match cases.last_mut() {
Some((name, rows)) if *name == f[0] => rows.push(f),
_ => cases.push((f[0], vec![f])),
}
}
assert_eq!(cases.len(), 10, "fixture shrank");
for (name, rows) in cases {
let dir = tempfile::tempdir().unwrap();
let mut cache = new_cache(&dir);
for f in rows {
let (op, member, room, nv, sc, sample, payload, ts, event) = (f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]);
let ctx = format!("case {name} op {op} event {event}");
let ts: i64 = ts.parse().unwrap_or(0);
let nv: Option<u32> = nv.parse().ok();
match op {
"reply" | "answer_raw" => {
if op == "reply" {
if let (Some(v), Some(s)) = (nv, scope(sc)) {
let k = NoticeAcceptance::cache_key(room, member, v).unwrap();
if cache.get::<NoticeAcceptance>(&k).unwrap().is_none() {
cache
.put(k, &NoticeAcceptance {
member_id: member.into(),
experiment_id: room.into(),
notice_version: v,
scope: s,
accepted_at: ts,
event_id: event.into(),
})
.unwrap();
}
}
}
let answer = Answer {
sample_id: sample.into(),
experiment_id: room.into(),
member_id: member.into(),
notice_version: nv,
scope: scope(sc),
text: Some(payload.into()),
exited: false,
received_at: ts,
event_id: event.into(),
text_hash: None,
};
answer.validate().unwrap();
put_if_later(&mut cache, &answer);
}
"reply_rejected" => {
let answer = Answer {
sample_id: sample.into(),
experiment_id: room.into(),
member_id: member.into(),
notice_version: nv,
scope: scope(sc),
text: Some(payload.into()),
exited: false,
received_at: ts,
event_id: event.into(),
text_hash: None,
};
assert!(answer.validate().is_err(), "{ctx}: must be refused");
}
"answer_revoked" => {
let answer = Answer {
sample_id: sample.into(),
experiment_id: room.into(),
member_id: member.into(),
notice_version: nv,
scope: scope(sc),
text: None,
exited: true,
received_at: ts,
event_id: event.into(),
text_hash: if payload.is_empty() { None } else { Some(hex(payload)) },
};
answer.validate().unwrap();
put_if_later(&mut cache, &answer);
}
"expect_winner" => {
let a = cache.get_required::<Answer>(&Answer::cache_key(room, sample, member).unwrap()).unwrap();
assert_eq!((a.event_id.as_str(), a.text.as_deref()), (event, Some(payload)), "{ctx}");
}
"redact" => {
let k = Answer::cache_key(room, sample, member).unwrap();
let a = cache.get_required::<Answer>(&k).unwrap();
assert_eq!(a.event_id, event, "{ctx}");
let hash: [u8; 32] = hex(payload).try_into().expect("tombstone hash is 32 bytes");
put_answer(&mut cache, &a.redacted(hash).unwrap());
}
"terms" => {
let terms = MemberTerms { member_id: member.into(), terms_version: payload.into(), terms_accepted_at: ts };
terms.validate().unwrap();
cache.put(MemberTerms::cache_key(member).unwrap(), &terms).unwrap();
}
"expect_terms" => {
let t = cache.get_required::<MemberTerms>(&MemberTerms::cache_key(member).unwrap()).unwrap();
assert_eq!((t.member_id.as_str(), t.terms_version.as_str(), t.terms_accepted_at), (member, payload, ts), "{ctx}");
}
"expect_no_terms" => assert!(cache.get::<MemberTerms>(&MemberTerms::cache_key(member).unwrap()).unwrap().is_none(), "{ctx}"),
"expect_counts" => {
let want = format!(
"answers={};acceptances={}",
cache.get_all::<Answer>().unwrap().len(),
cache.get_all::<NoticeAcceptance>().unwrap().len()
);
assert_eq!(want, payload, "{ctx}");
}
"expect_train" => assert_eq!(training_events(&cache).join(";"), payload, "{ctx}"),
"expect_exited" => {
let a = cache.get_required::<Answer>(&Answer::cache_key(room, sample, member).unwrap()).unwrap();
assert!(a.exited && a.text.is_none(), "{ctx}");
assert_eq!(a.text_hash, Some(hex(payload)), "{ctx}");
assert_eq!((a.event_id.as_str(), a.received_at, a.sample_id.as_str()), (event, ts, sample), "{ctx}");
}
"expect_acceptance" => {
let a = cache
.get_required::<NoticeAcceptance>(&NoticeAcceptance::cache_key(room, member, nv.unwrap()).unwrap())
.unwrap();
assert_eq!((a.notice_version, Some(a.scope), a.accepted_at), (nv.unwrap(), scope(sc), ts), "{ctx}");
assert_eq!(a.event_id, payload, "{ctx}");
}
"version_ok" | "version_refused" => {
// member column names the field; payload is the version text as it arrives
let ok = match member {
"notice_version" => check_version(payload).is_ok_and(|v| {
NoticeAcceptance {
member_id: "m".into(),
experiment_id: room.into(),
notice_version: v,
scope: Scope::Train,
accepted_at: 1,
event_id: "e".into(),
}
.validate()
.is_ok()
}),
"sample_id" => check_sample_id(&format!("s@{payload}")).is_ok(),
"terms_version" => {
MemberTerms { member_id: "m".into(), terms_version: payload.into(), terms_accepted_at: 1 }.validate().is_ok()
}
other => panic!("unknown version field {other}"),
};
assert_eq!(ok, op == "version_ok", "{ctx} field {member} version {payload}");
}
other => panic!("unknown op {other}"),
}
}
}
}
#[test]
fn records_survive_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let mut cache = new_cache(&dir);
let a = Answer {
sample_id: "s@1".into(),
experiment_id: "r".into(),
member_id: "m".into(),
notice_version: Some(2),
scope: Some(Scope::Train),
text: None,
exited: true,
received_at: 5,
event_id: "e".into(),
text_hash: Some(vec![7; 32]),
};
a.validate().unwrap();
put_answer(&mut cache, &a);
let reopened = new_cache(&dir);
assert_eq!(reopened.get_required::<Answer>(&Answer::cache_key("r", "s@1", "m").unwrap()).unwrap(), a);
}
#[test]
fn key_refuses_unversioned_sample_ids() {
for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1", "s@4294967296", "s@01", "s@00", "s@001"] {
assert!(Answer::cache_key("r", bad, "m").is_err(), "{bad}");
}
assert!(Answer::cache_key("r", "s@4294967295", "m").is_ok());
assert!(Answer::cache_key("r", "s@1", "m").unwrap() != Answer::cache_key("r", "s@2", "m").unwrap());
}
#[test]
fn one_version_rule_everywhere() {
for ok in ["1", "2", "4294967295"] {
let n: u32 = ok.parse().unwrap();
assert_eq!(check_version(ok).unwrap(), n, "{ok}");
assert_eq!(check_sample_id(&format!("s@{ok}")).unwrap(), ("s", n), "s@{ok}");
assert!(MemberTerms { member_id: "m".into(), terms_version: ok.into(), terms_accepted_at: 1 }.validate().is_ok(), "terms {ok}");
}
for bad in ["0", "4294967296", "4294967297", "8589934593", "99999999999999999999", "01", "+1", "-1", "", "2026-11"] {
assert!(check_version(bad).is_err(), "{bad}");
assert!(check_sample_id(&format!("s@{bad}")).is_err(), "s@{bad}");
assert!(MemberTerms { member_id: "m".into(), terms_version: bad.into(), terms_accepted_at: 1 }.validate().is_err(), "terms {bad}");
}
}
#[test]
fn notice_version_zero_is_refused_in_both_records() {
let acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 0,
scope: Scope::Train,
accepted_at: 1,
event_id: "e".into(),
};
assert!(acc.validate().is_err());
assert!(NoticeAcceptance { notice_version: u32::MAX, ..acc }.validate().is_ok());
}
#[test]
fn acceptance_after_the_answer_does_not_cover_it() {
let a = Answer {
sample_id: "s@1".into(),
experiment_id: "r".into(),
member_id: "m".into(),
notice_version: Some(1),
scope: Some(Scope::Train),
text: Some("t".into()),
exited: false,
received_at: 100,
event_id: "e".into(),
text_hash: None,
};
let acc = |at| NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 1,
scope: Scope::Train,
accepted_at: at,
event_id: "e".into(),
};
assert!(a.is_training_data(Some(&acc(100))));
assert!(!a.is_training_data(Some(&acc(101))));
assert!(!a.is_training_data(None));
}
fn answer() -> Answer {
Answer {
sample_id: "s@1".into(),
experiment_id: "r".into(),
member_id: "m".into(),
notice_version: Some(1),
scope: Some(Scope::Train),
text: Some("t".into()),
exited: false,
received_at: 100,
event_id: "e".into(),
text_hash: None,
}
}
#[test]
fn acceptance_for_another_notice_version_never_covers_the_answer() {
let acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 1,
scope: Scope::Train,
accepted_at: 1,
event_id: "e0".into(),
};
let mut a = answer();
assert!(a.is_training_data(Some(&acc)));
a.notice_version = Some(2);
assert!(!a.is_training_data(Some(&acc)), "a changed notice version needs a fresh first reply");
}
#[test]
fn training_needs_acceptance_scope_equal_to_the_answers_scope() {
let mut acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 1,
scope: Scope::Study,
accepted_at: 1,
event_id: "e0".into(),
};
assert!(!answer().is_training_data(Some(&acc)));
acc.scope = Scope::Train;
assert!(answer().is_training_data(Some(&acc)));
}
#[test]
fn a_reply_never_collected_gets_no_tombstone() {
let mut a = answer();
a.text = None;
assert!(a.redacted([0; 32]).is_err());
let t = answer().redacted([9; 32]).unwrap();
assert!(t.exited && t.text.is_none() && t.text_hash == Some(vec![9; 32]));
assert!(t.redacted([9; 32]).is_err(), "exit is one-way and a tombstone has no text to hash again");
}
#[test]
fn validate_enforces_the_representation_rules() {
assert!(answer().validate().is_ok());
let cases: Vec<(&str, Box<dyn Fn(&mut Answer)>)> = vec![
("empty member", Box::new(|a| a.member_id.clear())),
("empty event", Box::new(|a| a.event_id.clear())),
("version zero", Box::new(|a| a.notice_version = Some(0))),
("version without scope", Box::new(|a| a.scope = None)),
("scope without version", Box::new(|a| a.notice_version = None)),
("live without text", Box::new(|a| a.text = None)),
("live with empty text", Box::new(|a| a.text = Some(String::new()))),
("exited with text", Box::new(|a| a.exited = true)),
("hash on a live answer", Box::new(|a| a.text_hash = Some(vec![0; 32]))),
("short hash", Box::new(|a| {
a.exited = true;
a.text = None;
a.text_hash = Some(vec![0; 31]);
})),
];
for (name, break_it) in cases {
let mut a = answer();
break_it(&mut a);
assert!(a.validate().is_err(), "{name}");
}
let mut revoked = answer();
revoked.text = None;
revoked.exited = true;
assert!(revoked.validate().is_ok(), "empty text is valid on a revoked answer");
let mut unscoped = answer();
unscoped.notice_version = None;
unscoped.scope = None;
assert!(unscoped.validate().is_ok(), "a scopeless reply is stored");
let acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 0,
scope: Scope::Show,
accepted_at: 1,
event_id: "e".into(),
};
assert!(acc.validate().is_err());
assert!(MemberTerms { member_id: "m".into(), terms_version: String::new(), terms_accepted_at: 1 }.validate().is_err());
}
#[test]
fn later_reply_is_greater_received_at_then_bytewise_event_id() {
let at = |t, e: &str| Answer { received_at: t, event_id: e.into(), ..answer() };
assert!(at(2, "a").supersedes(&at(1, "z")));
assert!(!at(1, "z").supersedes(&at(2, "a")));
assert!(at(1, "a").supersedes(&at(1, "B")), "bytewise: 0x61 > 0x42");
assert!(at(1, "z").supersedes(&at(1, "aa")), "bytewise, not by length");
assert!(!at(1, "e").supersedes(&at(1, "e")));
}
#[test]
fn keys_join_the_parts_with_u001f_and_refuse_empty_or_separator_parts() {
assert_eq!(Answer::cache_key("r", "s@1", "m").unwrap(), "r\u{1f}s@1\u{1f}m");
assert_eq!(NoticeAcceptance::cache_key("r", "m", 3).unwrap(), "r\u{1f}m\u{1f}3");
assert_eq!(MemberTerms::cache_key("m").unwrap(), "m");
for (experiment, sample, member) in [("", "s@1", "m"), ("r", "s@1", ""), ("r\u{1f}x", "s@1", "m"), ("r", "s\u{1f}x@1", "m"), ("r", "s@1", "m\u{1f}x")] {
assert!(Answer::cache_key(experiment, sample, member).is_err(), "{experiment:?} {sample:?} {member:?}");
}
assert!(NoticeAcceptance::cache_key("", "m", 1).is_err());
assert!(NoticeAcceptance::cache_key("r", "", 1).is_err());
assert!(NoticeAcceptance::cache_key("r\u{1f}", "m", 1).is_err());
assert!(NoticeAcceptance::cache_key("r", "m\u{1f}", 1).is_err());
assert!(MemberTerms::cache_key("").is_err());
assert!(MemberTerms::cache_key("m\u{1f}").is_err());
// Parts that would collide if the separator were not refused or were `,` or empty.
assert_ne!(Answer::cache_key("a", "s@1", "bc").unwrap(), Answer::cache_key("ab", "s@1", "c").unwrap());
}
#[test]
fn an_acceptance_for_another_member_or_experiment_never_covers_the_answer() {
let acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 1,
scope: Scope::Train,
accepted_at: 1,
event_id: "e0".into(),
};
assert!(answer().is_training_data(Some(&acc)));
let other_member = NoticeAcceptance { member_id: "m2".into(), ..acc.clone() };
let other_experiment = NoticeAcceptance { experiment_id: "r2".into(), ..acc };
assert!(!answer().is_training_data(Some(&other_member)));
assert!(!answer().is_training_data(Some(&other_experiment)));
}
/// The doc states the rule once: the answer's scope is `train`, the acceptance's own scope is `train`,
/// and the two are equal. The code keeps all three conditions (any two imply the third, so a single
/// removal is an equivalent mutant); this pins the rule itself over every pair of scopes.
#[test]
fn only_a_train_answer_under_a_train_acceptance_is_training_data() {
let scopes = [Scope::Show, Scope::Study, Scope::Train];
for answer_scope in scopes {
for acceptance_scope in scopes {
let a = Answer { scope: Some(answer_scope), ..answer() };
let acc = NoticeAcceptance {
member_id: "m".into(),
experiment_id: "r".into(),
notice_version: 1,
scope: acceptance_scope,
accepted_at: 1,
event_id: "e0".into(),
};
let want = answer_scope == Scope::Train && acceptance_scope == Scope::Train;
assert_eq!(a.is_training_data(Some(&acc)), want, "answer {answer_scope:?} under acceptance {acceptance_scope:?}");
}
}
}

View file

@ -0,0 +1,78 @@
# case,op,member,experiment,notice_version,scope,sample_id,payload,ts,event_id
# Ops run in order against a real CultCache; each case starts empty. Timestamps are unix ms.
# reply: first reply under a (room, member, notice_version) writes an acceptance, then the answer replaces any earlier one at (room, sample_id, member); payload is the text.
# answer_raw: stores the answer with no acceptance written. redact: replaces the answer whose event_id matches with its tombstone; payload is the 32-byte tombstone hash as 64 hex chars (the member-side service's output; the crate never computes it).
# A reply with no established notice version or scope is stored with both absent (doc L107-114, Answer record), writes no acceptance, and is excluded. answer_raw with a version but no acceptance models a lost or never-written acceptance: still excluded (doc L152-158).
# reply_rejected: validate must refuse the answer (nothing stored). answer_revoked: stores an exited answer with no text; payload is the tombstone hash (empty means none). expect_winner: the stored answer's event_id and text must equal event and payload.
# Replacement at one key: the later reply is the greater (received_at, event_id), event_id compared bytewise; input order never decides (doc L98-101 and Heimdall PR #6 comment 759).
# terms: member field terms_version (payload) and terms_accepted_at (ts). expect_terms: the stored pair for that member reads back as payload and ts; expect_no_terms: the member has none. expect_*: payload is what must hold.
# version_ok / version_refused: the member column names the field (notice_version, sample_id, terms_version), payload is the version text; a sample id is checked as s@<payload>. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 d6a8d42, doc at aaeb175).
# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit aaeb175), not computed by the crate.
versioned-key,reply,m1,room1,1,train,s1@1,first,100,e1
versioned-key,reply,m1,room1,1,train,s1@2,second,200,e2
versioned-key,reply,m1,room1,1,train,s1@1,replaced,300,e3
versioned-key,expect_counts,,,,,,answers=2;acceptances=1,,
versioned-key,expect_train,,,,,,e2;e3,,
no-scope,reply,m2,room1,,,s1@1,unscoped,100,e4
no-scope,reply,m2,room1,,,s1@2,unversioned,110,e5
no-scope,answer_raw,m3,room1,1,train,s1@1,no-acceptance,120,e6
no-scope,reply,m4,room1,1,study,s1@1,study-only,130,e7
no-scope,reply,m5,room1,1,train,s1@1,trainable,140,e8
no-scope,expect_counts,,,,,,answers=5;acceptances=2,,
no-scope,expect_train,,,,,,e8,,
redacted-reply,reply,m6,room1,1,train,s1@1,secret words,100,e9
redacted-reply,reply,m7,room1,1,train,s1@1,kept,110,e10
redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,,e9
redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9
redacted-reply,expect_train,,,,,,e10,,
redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,,
newcomer,terms,m8,,,,,4294967295,50,
newcomer,expect_terms,m8,,,,,4294967295,50,
newcomer,expect_no_terms,m9,,,,,,,
newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11
newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12
newcomer,reply,m8,room1,4,study,s1@1,again,200,e13
newcomer,expect_acceptance,m8,room1,3,train,,e11,100,
newcomer,expect_acceptance,m8,room1,4,study,,e13,200,
newcomer,expect_acceptance,m9,room1,3,train,,e12,110,
newcomer,expect_counts,,,,,,answers=2;acceptances=3,,
newcomer,expect_train,,,,,,e12,,
version-change,reply,m10,room1,1,train,s1@1,under v1,100,e14
version-change,answer_raw,m10,room1,2,train,s1@2,v2 without a fresh first reply,200,e15
version-change,expect_counts,,,,,,answers=2;acceptances=1,,
version-change,expect_train,,,,,,e14,,
version-change,reply,m10,room1,2,train,s1@3,first reply under v2,300,e16
version-change,expect_counts,,,,,,answers=3;acceptances=2,,
version-change,expect_train,,,,,,e14;e16,,
empty-live-rejected,reply_rejected,m11,room1,,,s1@1,,100,e17
empty-live-rejected,expect_counts,,,,,,answers=0;acceptances=0,,
empty-live-rejected,reply,m11,room1,,,s1@1,now filled,110,e18
empty-live-rejected,expect_winner,m11,room1,,,s1@1,now filled,,e18
empty-revoked,answer_revoked,m12,room1,1,train,s1@1,,100,e19
empty-revoked,answer_revoked,m13,room1,1,train,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e20
empty-revoked,expect_counts,,,,,,answers=2;acceptances=0,,
empty-revoked,expect_train,,,,,,,,
tie-greater-first,reply,m14,room1,,,s1@1,upper-B,500,B
tie-greater-first,reply,m14,room1,,,s1@1,lower-a,500,a
tie-greater-first,reply,m15,room1,,,s1@1,short-z,500,z
tie-greater-first,reply,m15,room1,,,s1@1,long-aa,500,aa
tie-greater-first,reply,m16,room1,,,s1@1,newer-ts-first,600,a
tie-greater-first,reply,m16,room1,,,s1@1,older-ts-last,500,z
tie-greater-first,expect_winner,m14,room1,,,s1@1,lower-a,,a
tie-greater-first,expect_winner,m15,room1,,,s1@1,short-z,,z
tie-greater-first,expect_winner,m16,room1,,,s1@1,newer-ts-first,,a
tie-greater-last,reply,m14,room1,,,s1@1,lower-a,500,a
tie-greater-last,reply,m14,room1,,,s1@1,upper-B,500,B
tie-greater-last,reply,m15,room1,,,s1@1,long-aa,500,aa
tie-greater-last,reply,m15,room1,,,s1@1,short-z,500,z
tie-greater-last,expect_winner,m14,room1,,,s1@1,lower-a,,a
tie-greater-last,expect_winner,m15,room1,,,s1@1,short-z,,z
one-version-rule,version_ok,notice_version,room1,,,,4294967295,,version_ok-notice_version-4294967295
one-version-rule,version_refused,notice_version,room1,,,,4294967296,,version_refused-notice_version-4294967296
one-version-rule,version_refused,notice_version,room1,,,,01,,version_refused-notice_version-01
one-version-rule,version_ok,sample_id,room1,,,,4294967295,,version_ok-sample_id-4294967295
one-version-rule,version_refused,sample_id,room1,,,,4294967296,,version_refused-sample_id-4294967296
one-version-rule,version_refused,sample_id,room1,,,,01,,version_refused-sample_id-01
one-version-rule,version_ok,terms_version,room1,,,,4294967295,,version_ok-terms_version-4294967295
one-version-rule,version_refused,terms_version,room1,,,,4294967296,,version_refused-terms_version-4294967296
one-version-rule,version_refused,terms_version,room1,,,,01,,version_refused-terms_version-01
1 # case,op,member,experiment,notice_version,scope,sample_id,payload,ts,event_id
2 # Ops run in order against a real CultCache; each case starts empty. Timestamps are unix ms.
3 # reply: first reply under a (room, member, notice_version) writes an acceptance, then the answer replaces any earlier one at (room, sample_id, member); payload is the text.
4 # answer_raw: stores the answer with no acceptance written. redact: replaces the answer whose event_id matches with its tombstone; payload is the 32-byte tombstone hash as 64 hex chars (the member-side service's output; the crate never computes it).
5 # A reply with no established notice version or scope is stored with both absent (doc L107-114, Answer record), writes no acceptance, and is excluded. answer_raw with a version but no acceptance models a lost or never-written acceptance: still excluded (doc L152-158).
6 # reply_rejected: validate must refuse the answer (nothing stored). answer_revoked: stores an exited answer with no text; payload is the tombstone hash (empty means none). expect_winner: the stored answer's event_id and text must equal event and payload.
7 # Replacement at one key: the later reply is the greater (received_at, event_id), event_id compared bytewise; input order never decides (doc L98-101 and Heimdall PR #6 comment 759).
8 # terms: member field terms_version (payload) and terms_accepted_at (ts). expect_terms: the stored pair for that member reads back as payload and ts; expect_no_terms: the member has none. expect_*: payload is what must hold.
9 # version_ok / version_refused: the member column names the field (notice_version, sample_id, terms_version), payload is the version text; a sample id is checked as s@<payload>. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 d6a8d42, doc at aaeb175).
10 # Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit aaeb175), not computed by the crate.
11 versioned-key,reply,m1,room1,1,train,s1@1,first,100,e1
12 versioned-key,reply,m1,room1,1,train,s1@2,second,200,e2
13 versioned-key,reply,m1,room1,1,train,s1@1,replaced,300,e3
14 versioned-key,expect_counts,,,,,,answers=2;acceptances=1,,
15 versioned-key,expect_train,,,,,,e2;e3,,
16 no-scope,reply,m2,room1,,,s1@1,unscoped,100,e4
17 no-scope,reply,m2,room1,,,s1@2,unversioned,110,e5
18 no-scope,answer_raw,m3,room1,1,train,s1@1,no-acceptance,120,e6
19 no-scope,reply,m4,room1,1,study,s1@1,study-only,130,e7
20 no-scope,reply,m5,room1,1,train,s1@1,trainable,140,e8
21 no-scope,expect_counts,,,,,,answers=5;acceptances=2,,
22 no-scope,expect_train,,,,,,e8,,
23 redacted-reply,reply,m6,room1,1,train,s1@1,secret words,100,e9
24 redacted-reply,reply,m7,room1,1,train,s1@1,kept,110,e10
25 redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,,e9
26 redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9
27 redacted-reply,expect_train,,,,,,e10,,
28 redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,,
29 newcomer,terms,m8,,,,,4294967295,50,
30 newcomer,expect_terms,m8,,,,,4294967295,50,
31 newcomer,expect_no_terms,m9,,,,,,,
32 newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11
33 newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12
34 newcomer,reply,m8,room1,4,study,s1@1,again,200,e13
35 newcomer,expect_acceptance,m8,room1,3,train,,e11,100,
36 newcomer,expect_acceptance,m8,room1,4,study,,e13,200,
37 newcomer,expect_acceptance,m9,room1,3,train,,e12,110,
38 newcomer,expect_counts,,,,,,answers=2;acceptances=3,,
39 newcomer,expect_train,,,,,,e12,,
40 version-change,reply,m10,room1,1,train,s1@1,under v1,100,e14
41 version-change,answer_raw,m10,room1,2,train,s1@2,v2 without a fresh first reply,200,e15
42 version-change,expect_counts,,,,,,answers=2;acceptances=1,,
43 version-change,expect_train,,,,,,e14,,
44 version-change,reply,m10,room1,2,train,s1@3,first reply under v2,300,e16
45 version-change,expect_counts,,,,,,answers=3;acceptances=2,,
46 version-change,expect_train,,,,,,e14;e16,,
47 empty-live-rejected,reply_rejected,m11,room1,,,s1@1,,100,e17
48 empty-live-rejected,expect_counts,,,,,,answers=0;acceptances=0,,
49 empty-live-rejected,reply,m11,room1,,,s1@1,now filled,110,e18
50 empty-live-rejected,expect_winner,m11,room1,,,s1@1,now filled,,e18
51 empty-revoked,answer_revoked,m12,room1,1,train,s1@1,,100,e19
52 empty-revoked,answer_revoked,m13,room1,1,train,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e20
53 empty-revoked,expect_counts,,,,,,answers=2;acceptances=0,,
54 empty-revoked,expect_train,,,,,,,,
55 tie-greater-first,reply,m14,room1,,,s1@1,upper-B,500,B
56 tie-greater-first,reply,m14,room1,,,s1@1,lower-a,500,a
57 tie-greater-first,reply,m15,room1,,,s1@1,short-z,500,z
58 tie-greater-first,reply,m15,room1,,,s1@1,long-aa,500,aa
59 tie-greater-first,reply,m16,room1,,,s1@1,newer-ts-first,600,a
60 tie-greater-first,reply,m16,room1,,,s1@1,older-ts-last,500,z
61 tie-greater-first,expect_winner,m14,room1,,,s1@1,lower-a,,a
62 tie-greater-first,expect_winner,m15,room1,,,s1@1,short-z,,z
63 tie-greater-first,expect_winner,m16,room1,,,s1@1,newer-ts-first,,a
64 tie-greater-last,reply,m14,room1,,,s1@1,lower-a,500,a
65 tie-greater-last,reply,m14,room1,,,s1@1,upper-B,500,B
66 tie-greater-last,reply,m15,room1,,,s1@1,long-aa,500,aa
67 tie-greater-last,reply,m15,room1,,,s1@1,short-z,500,z
68 tie-greater-last,expect_winner,m14,room1,,,s1@1,lower-a,,a
69 tie-greater-last,expect_winner,m15,room1,,,s1@1,short-z,,z
70 one-version-rule,version_ok,notice_version,room1,,,,4294967295,,version_ok-notice_version-4294967295
71 one-version-rule,version_refused,notice_version,room1,,,,4294967296,,version_refused-notice_version-4294967296
72 one-version-rule,version_refused,notice_version,room1,,,,01,,version_refused-notice_version-01
73 one-version-rule,version_ok,sample_id,room1,,,,4294967295,,version_ok-sample_id-4294967295
74 one-version-rule,version_refused,sample_id,room1,,,,4294967296,,version_refused-sample_id-4294967296
75 one-version-rule,version_refused,sample_id,room1,,,,01,,version_refused-sample_id-01
76 one-version-rule,version_ok,terms_version,room1,,,,4294967295,,version_ok-terms_version-4294967295
77 one-version-rule,version_refused,terms_version,room1,,,,4294967296,,version_refused-terms_version-4294967296
78 one-version-rule,version_refused,terms_version,room1,,,,01,,version_refused-terms_version-01