heimdall-consent: one version rule for every version field (positive, no leading zeros, <= 4294967295)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-07 00:32:09 +00:00
commit af0b1b6817
4 changed files with 50 additions and 17 deletions

View file

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

View file

@ -35,17 +35,25 @@ fn nonempty(name: &str, value: &str) -> Result<()> {
Ok(())
}
/// `<sample>@<version>`, 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<u32> {
match version.parse::<u32>() {
Ok(v) if v > 0 && !version.starts_with(['+', '0']) => Ok(v),
_ => bail!("version {version:?} must be a positive integer, no leading zeros, at most {}", u32::MAX),
}
}
/// `<sample>@<version>`, the version under `check_version`. The version is part of the id, so it is
/// part of every answer key and no key can name a sample without one.
pub fn check_sample_id(sample_id: &str) -> Result<(&str, u32)> {
let Some((sample, version)) = sample_id.rsplit_once('@') else {
bail!("sample id {sample_id:?} has no @version");
};
match version.parse::<u32>() {
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`.

View file

@ -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>(&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 {

View file

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

1 # case,op,member,experiment,notice_version,scope,sample_id,payload,ts,event_id
25 redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9
26 redacted-reply,expect_train,,,,,,e10,,
27 redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,,
28 newcomer,terms,m8,,,,,2026-11,50, newcomer,terms,m8,,,,,4294967295,50,
29 newcomer,expect_terms,m8,,,,,2026-11,50, newcomer,expect_terms,m8,,,,,4294967295,50,
30 newcomer,expect_no_terms,m9,,,,,,,
31 newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11
32 newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12