From 309c8c1ba3af58af35a5664f550014415107efc9 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 15:35:25 +0000 Subject: [PATCH 1/8] heimdall-consent: typed records for the answer collector NoticeAcceptance, Answer and MemberTerms cut from Heimdall PR #6 (docs/answer-collector-consent.md, head d836f29), with a golden vector for versioned sample keys, scope-less exclusion from training, redaction as hash-only tombstone and newcomer notice acceptance. Ambiguities are listed in the README as questions for Heimdall. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/Cargo.lock | 716 ++++++++++++++++++ packages/heimdall-consent/Cargo.toml | 18 + packages/heimdall-consent/README.md | 21 + packages/heimdall-consent/src/lib.rs | 146 ++++ packages/heimdall-consent/tests/golden.rs | 200 +++++ .../tests/vectors/consent_golden.csv | 33 + 6 files changed, 1134 insertions(+) create mode 100644 packages/heimdall-consent/Cargo.lock create mode 100644 packages/heimdall-consent/Cargo.toml create mode 100644 packages/heimdall-consent/README.md create mode 100644 packages/heimdall-consent/src/lib.rs create mode 100644 packages/heimdall-consent/tests/golden.rs create mode 100644 packages/heimdall-consent/tests/vectors/consent_golden.csv diff --git a/packages/heimdall-consent/Cargo.lock b/packages/heimdall-consent/Cargo.lock new file mode 100644 index 00000000..758d85ea --- /dev/null +++ b/packages/heimdall-consent/Cargo.lock @@ -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" diff --git a/packages/heimdall-consent/Cargo.toml b/packages/heimdall-consent/Cargo.toml new file mode 100644 index 00000000..9c2b6729 --- /dev/null +++ b/packages/heimdall-consent/Cargo.toml @@ -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" diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md new file mode 100644 index 00000000..5e2c3284 --- /dev/null +++ b/packages/heimdall-consent/README.md @@ -0,0 +1,21 @@ +# heimdall-consent + +Typed CultCache records for Heimdall's answer collector: `NoticeAcceptance`, `Answer` and `MemberTerms`, +plus the two rules that need no store to state (`Answer::is_training_data`, `Answer::redacted`). Cut from +`docs/answer-collector-consent.md` in GameCult/Heimdall PR #6 (head d836f29); the doc's tables are the +contract. Specification only: no collector, tally or export 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. + +## Choices the doc does not fix (questions for Heimdall) + +1. Timestamps are unix milliseconds (`i64`), matching `origin_server_ts`. The doc says "UTC timestamp". +2. `member_id`, `experiment_id`, `event_id` are strings; `notice_version` is `u32`; `terms_version` a string. The doc names only "Heimdall member id" and "Matrix event id". +3. Key text is the parts joined with U+001F; a part that is empty or contains it is refused. Matrix ids can hold most other separators. +4. The tombstone's salted `text` hash has no row in the answer table. It is carried as `Answer.text_hash` (bytes) on the exited record. Which hash function, whose salt, and whether the tombstone is a separate record is undecided; the crate never computes the hash. +5. `Answer.notice_version` and `scope` are optional in the typed record so an answer lacking them can exist and be excluded; the table makes both required. Should such a reply be stored at all, given "is not collected"? +6. A reply under a notice with no recorded scope writes no acceptance in the vector's collector; the doc says the first reply accepts "the notice version then pinned" and does not cover a missing pin. +7. `MemberTerms` is a separate record keyed by `member_id`. The doc puts the fields on the member; the member record lives in Heimdall, not CultLib. +8. Training rule item 2 also requires the acceptance's scope to be `train` and to match the answer's experiment and member; the doc requires only that an acceptance exist for the key with `accepted_at <= received_at`. +9. The "separate store or partition" for records wider than `show` is not modelled here. diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs new file mode 100644 index 00000000..00dc3b48 --- /dev/null +++ b/packages/heimdall-consent/src/lib.rs @@ -0,0 +1,146 @@ +//! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md` +//! in GameCult/Heimdall PR #6 (head d836f29): the notice acceptance record, the answer record, and +//! the terms version on the member. Field names follow the doc's tables. Where the doc is silent +//! the choice made here is listed in README.md as a question for Heimdall. + +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. Ids containing it are refused rather than escaped. +const SEP: char = '\u{1f}'; + +fn key(parts: &[&str]) -> Result { + 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())) +} + +/// `@`, version a positive integer. 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, u64)> { + let Some((sample, version)) = sample_id.rsplit_once('@') else { + bail!("sample id {sample_id:?} has no @version"); + }; + match version.parse::() { + Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with('+') => Ok((sample, v)), + _ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer 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 { + /// `(experiment_id, member_id, notice_version)`. + pub fn cache_key(experiment_id: &str, member_id: &str, notice_version: u32) -> Result { + key(&[experiment_id, member_id, ¬ice_version.to_string()]) + } +} + +/// One answer. `notice_version` and `scope` are optional in the typed record only so that a stored +/// answer lacking them can be represented and excluded; the doc's table makes them required. +#[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, + #[cultcache(key = 4, default)] + pub scope: Option, + /// Absent once exited. + #[cultcache(key = 5, default)] + pub text: Option, + /// One-way: set by redaction, never cleared. + #[cultcache(key = 6, default)] + pub exited: bool, + /// `origin_server_ts` of the reply, unix milliseconds UTC. + #[cultcache(key = 7)] + pub received_at: i64, + #[cultcache(key = 8)] + pub event_id: String, + /// The tombstone's salted hash of `text`, present only once exited. Not in the doc's table: see README. + #[cultcache(key = 9, default)] + pub text_hash: Option>, +} + +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 { + check_sample_id(sample_id)?; + key(&[experiment_id, sample_id, member_id]) + } + + /// Redaction is exit: text erased, `exited` set, a hash-only tombstone kept. The hash is computed + /// by the caller; this crate never sees it salted or unsalted. + pub fn redacted(&self, text_hash: Vec) -> Answer { + Answer { text: None, exited: true, text_hash: Some(text_hash), ..self.clone() } + } + + /// Training rule: scope `train`, an acceptance for this notice version made no later than the + /// answer, and not exited. 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 + && acc.accepted_at <= self.received_at + } +} + +/// Newcomers only: the terms version recorded by the `m.login.terms` stage. Members who joined +/// before it, and Minds, have no record, and the collector never reads it or gates on it. +#[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 { + /// `member_id`. + pub fn cache_key(member_id: &str) -> Result { + key(&[member_id]) + } +} diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs new file mode 100644 index 00000000..6154545f --- /dev/null +++ b/packages/heimdall-consent/tests/golden.rs @@ -0,0 +1,200 @@ +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::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().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 { + 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 { + (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(); +} + +fn training_events(cache: &CultCache) -> Vec { + let mut ids: Vec = cache + .get_all::() + .unwrap() + .iter() + .filter(|a| { + let acc = a.notice_version.and_then(|v| { + cache.get::(&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::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(), 4, "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 = 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::(&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(); + } + } + } + put_answer(&mut cache, &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, + }); + } + "redact" => { + let k = Answer::cache_key(room, sample, member).unwrap(); + let a = cache.get_required::(&k).unwrap(); + assert_eq!(a.event_id, event, "{ctx}"); + put_answer(&mut cache, &a.redacted(hex(payload))); + } + "terms" => { + cache + .put(MemberTerms::cache_key(member).unwrap(), &MemberTerms { + member_id: member.into(), + terms_version: payload.into(), + terms_accepted_at: ts, + }) + .unwrap(); + } + "expect_counts" => { + let want = format!( + "answers={};acceptances={}", + cache.get_all::().unwrap().len(), + cache.get_all::().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::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::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}"); + } + 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![1, 2]), + }; + put_answer(&mut cache, &a); + let reopened = new_cache(&dir); + assert_eq!(reopened.get_required::(&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"] { + assert!(Answer::cache_key("r", bad, "m").is_err(), "{bad}"); + } + assert!(Answer::cache_key("r", "s@1", "m").unwrap() != Answer::cache_key("r", "s@2", "m").unwrap()); +} + +#[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)); +} diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv new file mode 100644 index 00000000..c966351b --- /dev/null +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -0,0 +1,33 @@ +# 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 hash as hex. +# terms: member field terms_version (payload) and terms_accepted_at (ts). expect_*: payload is what must hold. +# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, head d836f29), 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,1,,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,a1b2c3d4,,e9 +redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4,100,e9 +redacted-reply,expect_train,,,,,,e10,, +redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,, +newcomer,terms,m8,,,,,2026-11,50, +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,, -- 2.47.3 From a9a2d51c6acaf3a6233341ee928046626e335b54 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 18:27:06 +0000 Subject: [PATCH 2/8] heimdall-consent: match Heimdall's rulings at doc commit 2364979 Typed records, validate(), redacted([u8;32]) and the golden vectors follow the nine rulings on Heimdall PR #6: u32 versions, 32-byte tombstone hash on the exited Answer, scopeless replies stored with both fields absent, strict training rule, fresh first reply per notice version. README cites doc lines. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 37 ++++-- packages/heimdall-consent/src/lib.rs | 98 +++++++++++++--- packages/heimdall-consent/tests/golden.rs | 111 +++++++++++++++++- .../tests/vectors/consent_golden.csv | 18 ++- 4 files changed, 221 insertions(+), 43 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index 5e2c3284..670fc8b6 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -1,21 +1,32 @@ # heimdall-consent Typed CultCache records for Heimdall's answer collector: `NoticeAcceptance`, `Answer` and `MemberTerms`, -plus the two rules that need no store to state (`Answer::is_training_data`, `Answer::redacted`). Cut from -`docs/answer-collector-consent.md` in GameCult/Heimdall PR #6 (head d836f29); the doc's tables are the -contract. Specification only: no collector, tally or export lives here. +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 +`2364979`; the doc's tables and Representation section are the contract, and its rulings are on the PR +(comment 682). 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. -## Choices the doc does not fix (questions for Heimdall) +## Where each ruling lands (doc lines at 2364979) -1. Timestamps are unix milliseconds (`i64`), matching `origin_server_ts`. The doc says "UTC timestamp". -2. `member_id`, `experiment_id`, `event_id` are strings; `notice_version` is `u32`; `terms_version` a string. The doc names only "Heimdall member id" and "Matrix event id". -3. Key text is the parts joined with U+001F; a part that is empty or contains it is refused. Matrix ids can hold most other separators. -4. The tombstone's salted `text` hash has no row in the answer table. It is carried as `Answer.text_hash` (bytes) on the exited record. Which hash function, whose salt, and whether the tombstone is a separate record is undecided; the crate never computes the hash. -5. `Answer.notice_version` and `scope` are optional in the typed record so an answer lacking them can exist and be excluded; the table makes both required. Should such a reply be stored at all, given "is not collected"? -6. A reply under a notice with no recorded scope writes no acceptance in the vector's collector; the doc says the first reply accepts "the notice version then pinned" and does not cover a missing pin. -7. `MemberTerms` is a separate record keyed by `member_id`. The doc puts the fields on the member; the member record lives in Heimdall, not CultLib. -8. Training rule item 2 also requires the acceptance's scope to be `train` and to match the answer's experiment and member; the doc requires only that an acceptance exist for the key with `accepted_at <= received_at`. -9. The "separate store or partition" for records wider than `show` is not modelled here. +| Ruling | Doc | Crate | +|---|---|---| +| Timestamps are Unix ms UTC, signed 64-bit | L93-94 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | +| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L95-98 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` | +| Key parts joined with U+001F; empty part or part containing it refused | L99-100 | `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 | L74, L127-147, L186-187 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L143-144); `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 | L82-89 | `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 | L32-34, L47, L87-89 | 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 | L51-56 | `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` | L111-119 | `is_training_data` | +| One store per scope plus a store for scopeless replies | L120-125 | not modelled; the collector's layout owns it | + +## Questions for Heimdall + +1. May `text` be an empty string? The doc says `text` is "string, or absent" (L72) and ids are non-empty + (L95), but says nothing of answer text. The crate accepts `Some("")` and `redacted` tombstones it. +2. The doc says a later answer to the same key replaces the earlier by "latest `received_at` wins" + (L78-80). Equal `received_at` for two different events is unspecified. The crate holds no replace + rule; the collector owns it. diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index 00dc3b48..3c98cd47 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -1,7 +1,8 @@ //! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md` -//! in GameCult/Heimdall PR #6 (head d836f29): the notice acceptance record, the answer record, and -//! the terms version on the member. Field names follow the doc's tables. Where the doc is silent -//! the choice made here is listed in README.md as a question for Heimdall. +//! in GameCult/Heimdall PR #6 (doc commit 2364979): 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. Anything the doc +//! still leaves open is listed there as a question for Heimdall. use anyhow::{Result, bail}; use cultcache_rs::DatabaseEntry; @@ -17,7 +18,8 @@ pub enum Scope { Train, } -/// Separates key parts. Ids containing it are refused rather than escaped. +/// 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 { @@ -27,13 +29,20 @@ fn key(parts: &[&str]) -> Result { Ok(parts.join(&SEP.to_string())) } -/// `@`, version a positive integer. 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, u64)> { +fn nonempty(name: &str, value: &str) -> Result<()> { + if value.is_empty() { + bail!("{name} must be a non-empty string"); + } + Ok(()) +} + +/// `@`, version a positive unsigned 32-bit integer. 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"); }; - match version.parse::() { + match version.parse::() { Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with('+') => Ok((sample, v)), _ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer version"), } @@ -59,14 +68,27 @@ pub struct NoticeAcceptance { } 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 { key(&[experiment_id, member_id, ¬ice_version.to_string()]) } } -/// One answer. `notice_version` and `scope` are optional in the typed record only so that a stored -/// answer lacking them can be represented and excluded; the doc's table makes them required. +/// 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 { @@ -91,7 +113,8 @@ pub struct Answer { pub received_at: i64, #[cultcache(key = 8)] pub event_id: String, - /// The tombstone's salted hash of `text`, present only once exited. Not in the doc's table: see README. + /// 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>, } @@ -103,14 +126,44 @@ impl Answer { key(&[experiment_id, sample_id, member_id]) } - /// Redaction is exit: text erased, `exited` set, a hash-only tombstone kept. The hash is computed - /// by the caller; this crate never sees it salted or unsalted. - pub fn redacted(&self, text_hash: Vec) -> Answer { - Answer { text: None, exited: true, text_hash: Some(text_hash), ..self.clone() } + /// 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 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(()) } - /// Training rule: scope `train`, an acceptance for this notice version made no later than the - /// answer, and not exited. No recorded scope, version or acceptance means excluded. + /// 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 { + 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; @@ -120,12 +173,14 @@ impl Answer { && 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 } } -/// Newcomers only: the terms version recorded by the `m.login.terms` stage. Members who joined -/// before it, and Minds, have no record, and the collector never reads it or gates on it. +/// 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 { @@ -139,6 +194,11 @@ pub struct MemberTerms { } impl MemberTerms { + pub fn validate(&self) -> Result<()> { + nonempty("member_id", &self.member_id)?; + nonempty("terms_version", &self.terms_version) + } + /// `member_id`. pub fn cache_key(member_id: &str) -> Result { key(&[member_id]) diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs index 6154545f..88882842 100644 --- a/packages/heimdall-consent/tests/golden.rs +++ b/packages/heimdall-consent/tests/golden.rs @@ -59,7 +59,7 @@ fn golden_vectors() { _ => cases.push((f[0], vec![f])), } } - assert_eq!(cases.len(), 4, "fixture shrank"); + assert_eq!(cases.len(), 5, "fixture shrank"); for (name, rows) in cases { let dir = tempfile::tempdir().unwrap(); let mut cache = new_cache(&dir); @@ -87,7 +87,7 @@ fn golden_vectors() { } } } - put_answer(&mut cache, &Answer { + let answer = Answer { sample_id: sample.into(), experiment_id: room.into(), member_id: member.into(), @@ -98,13 +98,16 @@ fn golden_vectors() { received_at: ts, event_id: event.into(), text_hash: None, - }); + }; + answer.validate().unwrap(); + put_answer(&mut cache, &answer); } "redact" => { let k = Answer::cache_key(room, sample, member).unwrap(); let a = cache.get_required::(&k).unwrap(); assert_eq!(a.event_id, event, "{ctx}"); - put_answer(&mut cache, &a.redacted(hex(payload))); + let hash: [u8; 32] = hex(payload).try_into().expect("tombstone hash is 32 bytes"); + put_answer(&mut cache, &a.redacted(hash).unwrap()); } "terms" => { cache @@ -157,8 +160,9 @@ fn records_survive_a_reopen() { exited: true, received_at: 5, event_id: "e".into(), - text_hash: Some(vec![1, 2]), + 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::cache_key("r", "s@1", "m").unwrap()).unwrap(), a); @@ -166,9 +170,10 @@ fn records_survive_a_reopen() { #[test] fn key_refuses_unversioned_sample_ids() { - for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1"] { + for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1", "s@4294967296"] { 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()); } @@ -198,3 +203,97 @@ fn acceptance_after_the_answer_does_not_cover_it() { 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)> = 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)), + ("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 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()); +} diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index c966351b..c1bfe2f9 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -1,15 +1,16 @@ # 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 hash as hex. +# 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 L82-89, 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 L120). # terms: member field terms_version (payload) and terms_accepted_at (ts). expect_*: payload is what must hold. -# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, head d836f29), not computed by the crate. +# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 2364979), 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,1,,s1@1,unscoped,100,e4 +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 @@ -18,8 +19,8 @@ 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,a1b2c3d4,,e9 -redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4,100,e9 +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,,,,,2026-11,50, @@ -31,3 +32,10 @@ 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,, -- 2.47.3 From 5ab6a6181f673b16ba0510699e155472aeeb741e Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 19:42:24 +0000 Subject: [PATCH 3/8] heimdall-consent: live answers need text; ties on received_at break by bytewise event_id Rulings: Heimdall PR #6 comment 759. Golden vectors for empty live text, empty revoked, and received_at ties in both input orders. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/src/lib.rs | 13 +++- packages/heimdall-consent/tests/golden.rs | 64 ++++++++++++++++++- .../tests/vectors/consent_golden.csv | 25 ++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index 3c98cd47..ee6be615 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -108,7 +108,8 @@ pub struct Answer { /// One-way: set by redaction, never cleared. #[cultcache(key = 6, default)] pub exited: bool, - /// `origin_server_ts` of the reply, unix milliseconds UTC. + /// Assigned by the collector on receipt, unix milliseconds UTC (the reply's `origin_server_ts` + /// in practice); ties are broken by `supersedes`. #[cultcache(key = 7)] pub received_at: i64, #[cultcache(key = 8)] @@ -141,6 +142,9 @@ impl Answer { 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"); @@ -149,6 +153,13 @@ impl 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. diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs index 88882842..23a9161d 100644 --- a/packages/heimdall-consent/tests/golden.rs +++ b/packages/heimdall-consent/tests/golden.rs @@ -31,6 +31,15 @@ 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::(&k).unwrap().is_some_and(|old| !a.supersedes(&old)) { + return; + } + cache.put(k, a).unwrap(); +} + fn training_events(cache: &CultCache) -> Vec { let mut ids: Vec = cache .get_all::() @@ -59,7 +68,7 @@ fn golden_vectors() { _ => cases.push((f[0], vec![f])), } } - assert_eq!(cases.len(), 5, "fixture shrank"); + assert_eq!(cases.len(), 9, "fixture shrank"); for (name, rows) in cases { let dir = tempfile::tempdir().unwrap(); let mut cache = new_cache(&dir); @@ -100,7 +109,42 @@ fn golden_vectors() { text_hash: None, }; answer.validate().unwrap(); - put_answer(&mut cache, &answer); + 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::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(); @@ -269,6 +313,8 @@ fn validate_enforces_the_representation_rules() { ("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| { @@ -282,6 +328,10 @@ fn validate_enforces_the_representation_rules() { 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; @@ -297,3 +347,13 @@ fn validate_enforces_the_representation_rules() { 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"))); +} diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index c1bfe2f9..751f4586 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -3,6 +3,8 @@ # 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 L82-89, 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 L120). +# 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 L78-80 and Heimdall PR #6 comment 759). # terms: member field terms_version (payload) and terms_accepted_at (ts). expect_*: payload is what must hold. # Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 2364979), not computed by the crate. versioned-key,reply,m1,room1,1,train,s1@1,first,100,e1 @@ -39,3 +41,26 @@ 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 -- 2.47.3 From 19f77ae3c272f7ae1f080921340928d079198773 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 19:42:44 +0000 Subject: [PATCH 4/8] heimdall-consent: README maps doc lines at Heimdall commit 4e99bfa Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 33 +++++++++---------- packages/heimdall-consent/src/lib.rs | 9 +++-- .../tests/vectors/consent_golden.csv | 6 ++-- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index 670fc8b6..bac41338 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -3,30 +3,29 @@ 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 -`2364979`; the doc's tables and Representation section are the contract, and its rulings are on the PR -(comment 682). Specification only: no collector, tally, export or tombstone hashing lives here. +`4e99bfa`; 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 2364979) +## Where each ruling lands (doc lines at 4e99bfa) | Ruling | Doc | Crate | |---|---|---| -| Timestamps are Unix ms UTC, signed 64-bit | L93-94 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | -| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L95-98 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` | -| Key parts joined with U+001F; empty part or part containing it refused | L99-100 | `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 | L74, L127-147, L186-187 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L143-144); `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 | L82-89 | `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 | L32-34, L47, L87-89 | acceptance key includes the version; `is_training_data` accepts only the acceptance for the answer's own version; vector case `version-change` | +| Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | +| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L100-103 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` | +| Key parts joined with U+001F; empty part or part containing it refused | L104-105 | `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 | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `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 | L87-94 | `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 | L32-34, L47, L92-94 | 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 | L51-56 | `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` | L111-119 | `is_training_data` | -| One store per scope plus a store for scopeless replies | L120-125 | not modelled; the collector's layout owns it | +| Training needs acceptance `scope = train` equal to the answer's `scope`, and `accepted_at <= received_at` | L116-123 | `is_training_data` | +| One store per scope plus a store for scopeless replies | L125-130 | not modelled; the collector's layout owns it | -## Questions for Heimdall +Rulings of comment 759 (doc L72, L78-85; no open questions remain): -1. May `text` be an empty string? The doc says `text` is "string, or absent" (L72) and ids are non-empty - (L95), but says nothing of answer text. The crate accepts `Some("")` and `redacted` tombstones it. -2. The doc says a later answer to the same key replaces the earlier by "latest `received_at` wins" - (L78-80). Equal `received_at` for two different events is unspecified. The crate holds no replace - rule; the collector owns it. +| 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) | L72, L83-85 | `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 | L75, L78-81 | `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` | diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index ee6be615..0119371f 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -1,8 +1,7 @@ //! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md` -//! in GameCult/Heimdall PR #6 (doc commit 2364979): the notice acceptance record, the answer record, +//! in GameCult/Heimdall PR #6 (doc commit 4e99bfa): 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. Anything the doc -//! still leaves open is listed there as a question for Heimdall. +//! doc's tables and its Representation section; README.md cites the doc lines. use anyhow::{Result, bail}; use cultcache_rs::DatabaseEntry; @@ -108,8 +107,8 @@ pub struct Answer { /// One-way: set by redaction, never cleared. #[cultcache(key = 6, default)] pub exited: bool, - /// Assigned by the collector on receipt, unix milliseconds UTC (the reply's `origin_server_ts` - /// in practice); ties are broken by `supersedes`. + /// Assigned by the collector on receipt, unix milliseconds UTC, not copied from the reply (doc L75); + /// ties are broken by `supersedes`. #[cultcache(key = 7)] pub received_at: i64, #[cultcache(key = 8)] diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index 751f4586..f11db809 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -2,11 +2,11 @@ # 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 L82-89, 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 L120). +# A reply with no established notice version or scope is stored with both absent (doc L87-94, 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 L119-122). # 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 L78-80 and Heimdall PR #6 comment 759). +# Replacement at one key: the later reply is the greater (received_at, event_id), event_id compared bytewise; input order never decides (doc L78-81 and Heimdall PR #6 comment 759). # terms: member field terms_version (payload) and terms_accepted_at (ts). expect_*: payload is what must hold. -# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 2364979), not computed by the crate. +# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 4e99bfa), 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 -- 2.47.3 From 27d7e48cd6c38850ffea07025fdb3eefb09c9426 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 6 Nov 2026 20:53:13 +0000 Subject: [PATCH 5/8] heimdall-consent: pin key(), MemberTerms read-back, acceptance member/experiment, scope rule; refuse leading-zero versions Answers Heimdall's review of PR #12 (review 4): tests for the exact U+001F joined key and the empty and separator refusals; an expect_terms vector op (newcomer case defends the MemberTerms pair) and expect_no_terms; a mismatched member or experiment acceptance is never training data; the three scope conditions kept, with a test of the rule over every scope pair; sample versions with a leading zero are refused (README notes Heimdall may rule normalise instead). Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 2 +- packages/heimdall-consent/src/lib.rs | 9 +-- packages/heimdall-consent/tests/golden.rs | 65 ++++++++++++++++++- .../tests/vectors/consent_golden.csv | 4 +- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index bac41338..ed89b9d0 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -14,7 +14,7 @@ Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, r | Ruling | Doc | Crate | |---|---|---| | Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | -| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L100-103 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` | +| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L100-103 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` and refuses a leading zero in the version (`s@01`) so one version has one key; the doc says only "positive integer", so Heimdall may instead rule that the crate normalises | | Key parts joined with U+001F; empty part or part containing it refused | L104-105 | `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 | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `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 | L87-94 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` | diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index 0119371f..a9a75ebb 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -35,15 +35,16 @@ fn nonempty(name: &str, value: &str) -> Result<()> { Ok(()) } -/// `@`, version a positive unsigned 32-bit integer. The version is part of the id, -/// so it is part of every answer key and no key can name a sample without one. +/// `@`, version a positive unsigned 32-bit integer written without leading zeros +/// (`s@01` and `s@1` would be two keys for one 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"); }; match version.parse::() { - Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with('+') => Ok((sample, v)), - _ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer version"), + Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with(['+', '0']) => Ok((sample, v)), + _ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer version without leading zeros"), } } diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs index 23a9161d..af6953b5 100644 --- a/packages/heimdall-consent/tests/golden.rs +++ b/packages/heimdall-consent/tests/golden.rs @@ -162,6 +162,11 @@ fn golden_vectors() { }) .unwrap(); } + "expect_terms" => { + let t = cache.get_required::(&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::cache_key(member).unwrap()).unwrap().is_none(), "{ctx}"), "expect_counts" => { let want = format!( "answers={};acceptances={}", @@ -214,7 +219,7 @@ fn records_survive_a_reopen() { #[test] fn key_refuses_unversioned_sample_ids() { - for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1", "s@4294967296"] { + 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()); @@ -357,3 +362,61 @@ fn later_reply_is_greater_received_at_then_bytewise_event_id() { 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:?}"); + } + } +} diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index f11db809..a7e5d553 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -5,7 +5,7 @@ # A reply with no established notice version or scope is stored with both absent (doc L87-94, 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 L119-122). # 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 L78-81 and Heimdall PR #6 comment 759). -# terms: member field terms_version (payload) and terms_accepted_at (ts). expect_*: payload is what must hold. +# 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. # Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 4e99bfa), 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 @@ -26,6 +26,8 @@ redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b redacted-reply,expect_train,,,,,,e10,, redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,, newcomer,terms,m8,,,,,2026-11,50, +newcomer,expect_terms,m8,,,,,2026-11,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 -- 2.47.3 From af0b1b68172fa0240c52becaa35add4aeeb60e98 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 00:32:09 +0000 Subject: [PATCH 6/8] heimdall-consent: one version rule for every version field (positive, no leading zeros, <= 4294967295) check_version is the single rule; the sample id's @N and MemberTerms.terms_version both call it, so terms_version "2026-11" is now refused and the golden vector carries 4294967295 (the harness now validates the terms record). notice_version stays u32 and validate() refuses 0. Tests: boundary table (1, 4294967295 accepted; 0, 4294967296, 4294967297, 01, +1, -1, empty, 2026-11 refused) across all three; each rule checked by mutation (u64 wrap, leading-zero, sign, terms unchecked, returned value, empty sample, zero in both records) and killed. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 2 +- packages/heimdall-consent/src/lib.rs | 22 +++++++---- packages/heimdall-consent/tests/golden.rs | 39 +++++++++++++++---- .../tests/vectors/consent_golden.csv | 4 +- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index ed89b9d0..fb446e64 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -14,7 +14,7 @@ Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, r | Ruling | Doc | Crate | |---|---|---| | Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | -| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L100-103 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` and refuses a leading zero in the version (`s@01`) so one version has one key; the doc says only "positive integer", so Heimdall may instead rule that the crate normalises | +| 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) | L100-103 | `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 | | Key parts joined with U+001F; empty part or part containing it refused | L104-105 | `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 | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `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 | L87-94 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` | diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index a9a75ebb..508e3a67 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -35,17 +35,25 @@ fn nonempty(name: &str, value: &str) -> Result<()> { Ok(()) } -/// `@`, version a positive unsigned 32-bit integer written without leading zeros -/// (`s@01` and `s@1` would be two keys for one 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. +/// 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 { + match version.parse::() { + 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), + } +} + +/// `@`, 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"); }; - match version.parse::() { - Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with(['+', '0']) => Ok((sample, v)), - _ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer version without leading zeros"), + 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. @@ -207,7 +215,7 @@ pub struct MemberTerms { impl MemberTerms { pub fn validate(&self) -> Result<()> { nonempty("member_id", &self.member_id)?; - nonempty("terms_version", &self.terms_version) + check_version(&self.terms_version).map(drop) } /// `member_id`. diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs index af6953b5..01e9477b 100644 --- a/packages/heimdall-consent/tests/golden.rs +++ b/packages/heimdall-consent/tests/golden.rs @@ -154,13 +154,9 @@ fn golden_vectors() { put_answer(&mut cache, &a.redacted(hash).unwrap()); } "terms" => { - cache - .put(MemberTerms::cache_key(member).unwrap(), &MemberTerms { - member_id: member.into(), - terms_version: payload.into(), - terms_accepted_at: ts, - }) - .unwrap(); + 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::cache_key(member).unwrap()).unwrap(); @@ -226,6 +222,35 @@ fn key_refuses_unversioned_sample_ids() { 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 { diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index a7e5d553..1323aef8 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -25,8 +25,8 @@ redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5 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,,,,,2026-11,50, -newcomer,expect_terms,m8,,,,,2026-11,50, +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 -- 2.47.3 From 420eb9e840d819d320fd88e641643c8967a08b64 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 02:40:57 +0000 Subject: [PATCH 7/8] heimdall-consent: pin the one version rule in the golden vectors (4294967295 ok; 4294967296 and 01 refused) Case one-version-rule runs version_ok/version_refused for notice_version, the sample id @N and terms_version; the Rust vector test handles both ops. README cites Heimdall PR #6 d6a8d42 for the rule. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 2 +- packages/heimdall-consent/tests/golden.rs | 25 ++++++++++++++++++- .../tests/vectors/consent_golden.csv | 10 ++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index fb446e64..e38c8318 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -14,7 +14,7 @@ Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, r | Ruling | Doc | Crate | |---|---|---| | Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `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) | L100-103 | `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 | +| 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) | L100-103 | `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 | L104-105 | `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 | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `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 | L87-94 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` | diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs index 01e9477b..615d5cb4 100644 --- a/packages/heimdall-consent/tests/golden.rs +++ b/packages/heimdall-consent/tests/golden.rs @@ -68,7 +68,7 @@ fn golden_vectors() { _ => cases.push((f[0], vec![f])), } } - assert_eq!(cases.len(), 9, "fixture shrank"); + assert_eq!(cases.len(), 10, "fixture shrank"); for (name, rows) in cases { let dir = tempfile::tempdir().unwrap(); let mut cache = new_cache(&dir); @@ -185,6 +185,29 @@ fn golden_vectors() { 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}"), } } diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index 1323aef8..4343b0b2 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -6,6 +6,7 @@ # 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 L78-81 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@. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 cafdc52). # Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 4e99bfa), 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 @@ -66,3 +67,12 @@ 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 -- 2.47.3 From 638e71f42724bb7a3f401acea17468aa029fefcf Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 04:10:44 +0000 Subject: [PATCH 8/8] heimdall-consent: re-pin docs to Heimdall PR #6 aaeb175, fix doc line numbers, mark notice state event and registration-checkbox gap as not modelled No behaviour or vector rows changed. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-consent/README.md | 33 +++++++++++-------- packages/heimdall-consent/src/lib.rs | 4 +-- .../tests/vectors/consent_golden.csv | 8 ++--- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index e38c8318..3782e720 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -3,29 +3,34 @@ 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 -`4e99bfa`; the doc's tables and Representation section are the contract, and its rulings are on the PR +`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 4e99bfa) +## Where each ruling lands (doc lines at aaeb175) | Ruling | Doc | Crate | |---|---|---| -| Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `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) | L100-103 | `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 | L104-105 | `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 | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `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 | L87-94 | `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 | L32-34, L47, L92-94 | 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 | L51-56 | `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` | L116-123 | `is_training_data` | -| One store per scope plus a store for scopeless replies | L125-130 | not modelled; the collector's layout owns it | +| 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 L72, L78-85; no open questions remain): +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) | L72, L83-85 | `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 | L75, L78-81 | `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` | +| 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. diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs index 508e3a67..61ecb188 100644 --- a/packages/heimdall-consent/src/lib.rs +++ b/packages/heimdall-consent/src/lib.rs @@ -1,5 +1,5 @@ //! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md` -//! in GameCult/Heimdall PR #6 (doc commit 4e99bfa): the notice acceptance record, the answer record, +//! 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. @@ -116,7 +116,7 @@ pub struct Answer { /// 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 L75); + /// 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, diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv index 4343b0b2..4f6e4048 100644 --- a/packages/heimdall-consent/tests/vectors/consent_golden.csv +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -2,12 +2,12 @@ # 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 L87-94, 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 L119-122). +# 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 L78-81 and Heimdall PR #6 comment 759). +# 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@. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 cafdc52). -# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit 4e99bfa), not computed by the crate. +# 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@. 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 -- 2.47.3