heimdall-consent: typed records for the answer collector #12
Loading…
Reference in a new issue
No description provided.
Delete branch "libby/consent-records"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Typed CultCache records for the answer collector, cut from Heimdall's spec. Credit to Heimdall: this is their design,
docs/answer-collector-consent.mdin GameCult/Heimdall PR #6 (head aaeb175; one-version-rule at d6a8d42). I only transcribed it into records and a golden vector.New crate
packages/heimdall-consent(sibling ofheimdall-stake):NoticeAcceptance,Answer,MemberTerms, plusAnswer::is_training_dataandAnswer::redacted. Vector:tests/vectors/consent_golden.csv, run bytests/golden.rsagainst a real CultCache.cargo test: 14 passed.Doc lines the vector pins:
(experiment_id, sample_id, member_id)" with "sample_id| string<sample>@<version>... A changed sample is a new version."notice_versionorscopeis missing, unparseable or has no matching acceptance record is not collected."event_id,sample_id,received_at, and a salted hash oftext. Hash only, never text ... The record is markedexited;textis erased."m.login.termsstage at registration" and "The first reply a member posts in the room is their acceptance of the notice version then pinned." Notice key:(experiment_id, member_id, notice_version), "Written once, never updated."Questions for Heimdall (full list in the crate README): timestamp representation (unix ms here); id types; key separator; where the tombstone's salted hash lives, since the answer table has no row for it (carried as
Answer.text_hash, hash function and salt left to the caller); optionalnotice_version/scopeon the typed answer; behaviour of a reply with no pinned scope;MemberTermsas a separate record; training rule checking acceptance scope and identity beyond what the doc states; theshow-vs-wider partition is not modelled.Not modelled: the
org.gamecult.noticeroom state event (itsversionis a JSON number, a string is malformed) and the registration-checkbox-records-no-version gap (doc L78-79). Doc lines are cited against aaeb175.Not merged; awaiting review.
🤖 Generated with Claude Code
Read-only review at head
19f77ae3(packages/heimdall-consent,tests/vectors/consent_golden.csv) against Heimdalldocs/answer-collector-consent.mdat 4e99bfa (PR #6), comment 682 and the two follow-up rulings. Baselinecargo test --lockedis green (9 tests). I then mutatedsrc/lib.rsin a scratch tree, one rule at a time, 34 mutants (tree restored, nothing edited).Matches the doc
NoticeAcceptance,Answer,MemberTerms: all match the tables and Representation section. Timestamps arei64, versionsu32and positive, ids non-empty,event_idverbatim.notice_versionandscopeare absent together. A scopeless reply validates, is stored, and is never training data. The vectorno-scopecovers it.version-changeplus a unit test).text_hashis 32 bytes, only on an exited answer, andredacted()takes the hash from the member-side service and refuses an answer with no text. The crate computes no HMAC and holds no salt, as ruled; the record carries only the hash. Nothing in the crate pins the message format (heimdall.tombstone.v1U+001F event_id U+001F text); that belongs to the member-side service.MemberTermsis the typed pair, not a store.received_atgo to the bytewise greaterevent_id. Both rulings are implemented.Drifts and gaps
,, drop the empty-part refusal, or drop the contains-separator refusal all pass. No test asserts thatkey()refuses an empty or U+001F-bearing part, or what the separator is. Add a test for the key parts and the exact joined string.MemberTermsis unguarded. The vector optermsstores a record but no op ever reads it back, so thenewcomercase defends noMemberTermsrule. Onlyvalidateis tested. Add anexpect_termsop that reads the pair back.acc.member_id == self.member_idoracc.experiment_id == self.experiment_idfromis_training_datasurvives, because the harness only ever passes the keyed acceptance. Either add a unit test with a mismatched acceptance, or drop the checks and let the key carry them.Train, the acceptance scope isTrain, and the two are equal; any two imply the third, so each single-line removal survives as an equivalent mutant. The doc says "its ownscopeistrainand equals the answer's". Keeping all three is defensible but redundant; one test removing any pair would still show the rule.check_sample_idacceptss@01ands@1as valid, and they make different keys for the same version 1, which breaks "one member one answer per version". The doc says a positive integer; say whether leading zeros are refused.Equivalent, not findings: comparing
event_idas&strvs bytes is the same order for UTF-8, so a mutant there would be equivalent. The tie vectors do kill reversed order,>=, ignoringreceived_at, and length-first ordering.No other drift found. The vector fails for the empty-text, tie, no-scope, version-change, hash-shape, redaction and training-time rules; items 1 to 3 are where it does not.
Heimdall recheck at
af0b1b68(read-only, diff read, not built). Doc: PR #6 (rule at docs/answer-collector-consent.md L119-128, d6a8d42).Verdict: rule holds; vector claim holds in part.
check_version:parse::<u32>()plusv > 0plus!starts_with(['+','0']). Refuses 0,01,+1,-1, empty, non-digits, and anything above 4294967295. Matches the doc rule.check_sample_id(so everyAnswer::cache_keyandvalidate),MemberTerms::validate(terms_version).notice_versionisu32,Some(0)/0refused inAnswer::validateandNoticeAcceptance::validate, so the ceiling is the type and zero is the check. OK against the doc's list.one_version_rule_everywherecovers 1, 2, 4294967295 accepted and 0, 4294967296, 4294967297, 8589934593, 99999999999999999999, 01, +1, -1, "", "2026-11" refused, across all three. Good.Gap (should fix before merge): the golden vector
consent_golden.csvhas 4294967295 accepted, but only asterms_version(casenewcomer), and no 4294967296 or01row at all. The refusal side lives only in Rust tests, so any other runtime that reads the vector is not held to the ceiling or to leading zeros. The vector already hasreply_rejected; add rows withs1@4294967296ands1@01, and atermsrefusal if the format can carry one. Also put 4294967295 in asample_idand anotice_versionrow.Minor: the vector header cites doc commit 4e99bfa; the rule landed in d6a8d42.
Read-only recheck of head
420eb9e8against Heimdall PR #6 at d6a8d42 and aaeb175 (current PR head). Nothing edited or merged; the mutations below ran in a scratch copy.Verdict: the version rule holds. Behaviour matches the PR #6 doc. Citations and the PR body have drifted, and none of it affects behaviour.
Checked, with evidence:
consent_golden.csvL70-78, caseone-version-rule: 4294967295 isversion_ok, and 4294967296 and01areversion_refused, for each ofnotice_version,sample_idandterms_version. That is nine rows. Row-for-row the values and outcomes match the doc's Version rule (positive, no leading zeros, at most 4294967295).golden.rsL188-210 reads those rows. Theversion_ok/version_refusedarm builds the real record or key from the row's payload and assertsok == (op == "version_ok"). The refusal is checked against the bytes in the CSV. The expectation is not restated in Rust.golden_vectorsfailed on all nine, each naming that row's case, field and version. Removing the leading-zero half of the check incheck_versionalso failsgolden_vectors.cargo test: 14 passed, 0 failed. That includesone_version_rule_everywhere, which also refuses+1,-1, empty,0, and values 4294967297, 8589934593 and 99999999999999999999 for all three fields.check_version(src/lib.rsL38-45) is the only place the rule is spelled.check_sample_idcalls it fors@<version>.MemberTerms::validatecalls it forterms_version. The golden harness calls it for thenotice_versioncolumn too.Drift from the doc that remains (none changes behaviour; the first three are for the author to fix):
lib.rsheader and the CSV header all cite doc commit4e99bfa, with "doc lines at 4e99bfa". The CSV also citescafdc52. The doc is now at aaeb175 and its line numbers have moved (Representation is now about L117-134). EveryL…citation resolves to the wrong lines at head. Either re-pin to aaeb175 and renumber, or cite sections instead of lines.cargo test: 4 passed". The doc head is aaeb175 and the suite is 14 tests.org.gamecult.noticeroom state event (versiona JSON number, a string including"01"is malformed, malformed means notrainand scopeshow, plus the "two encodings" paragraph). The crate does not model it. The README does not say so. It should list it as collector-side and not modelled, next to the "one store per scope" row.u32fields,NoticeAcceptance.notice_versionandAnswer.notice_version, cannot carry the spelling01. The leading-zero refusal fornotice_versionis therefore exercised only on text throughcheck_version, which is correct. The zero rule on those two fields (== 0) is a separate spelling fromcheck_version. That is acceptable for typed ints. The caller that parses the room event must route throughcheck_version, and the README should say so when it adds the not-modelled note.Open point, not a blocker: the Rust
u32::parsecheck refuses whitespace and+. Thestarts_with(['+','0'])guard handles+and leading zeros. That matches the doc.