diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md new file mode 100644 index 0000000..ca8175e --- /dev/null +++ b/docs/answer-collector-consent.md @@ -0,0 +1,265 @@ +# Answer Collector: Record and Consent + +Status: specification. Nothing here is implemented. It fixes what an answer +record is and what a respondent has agreed to, so that a collector, a tally and +a training export can be built against one definition. Field names and types +below are the contract for the typed CultCache records. + +Scope: free-text and choice answers that members give to a sample put to them +by an experiment. Not identity proof (see `architecture.md`). Personal details +(names, contact data, account identifiers, anything that identifies a person) are +out of scope: the collector does not ask for them and a sample must not solicit +them. The only identifier it keeps is `member_id`, and that stays on the reward +path. + +## Notice + +Consent is declared once per room, not per reply. Each experiment room carries +one notice that names the scope the room's answers may be used for and the way +out. A notice has a `notice_version`; any change to its wording or scope is a new +version. + +### Where the notice lives + +A room's notice is a Matrix room state event: type `org.gamecult.notice`, empty +`state_key`, content `{version, scope}`. + +- `version` follows the version rule under Representation. +- `scope` is `show`, `study` or `train`. +- The event is set under the room's power levels. A pinned message is not + accepted as a notice: anyone who can pin could forge it. +- Absent or malformed (a missing or unknown field, a `version` the version rule + does not accept, an unknown `scope`) means no `train` + line and the narrowest scope, `show`. Malformed content is never repaired. + +The wording shown to respondents is the room's own business; the collector reads +only `version` and `scope`. A change to either is a new state event and so a +new `version`. + +| scope | what the notice allows | +|---|---| +| `show` | display back inside the experiment (to the respondent, and in aggregate to participants). Nothing else. | +| `study` | `show`, plus analysis by the experiment's researchers. Quoted in findings only unattributed. | +| `train` | `study`, plus use as training or evaluation data for models. | + +`train` is a declared scope: it is used only when the notice for that room says +`train` in terms. It is never inferred, never a default, and never raised by a +reply. + +The first reply a member posts in the room is their acceptance of the notice +version then in force. No separate click, command or form. A later notice version +needs a new acceptance: the member's first reply under it. + +### Notice acceptance record + +| field | type | rule | +|---|---|---| +| `member_id` | Heimdall member id | the accepting member. | +| `experiment_id` | string | the room. | +| `notice_version` | integer | the `version` of the room's notice state event when the reply was received. | +| `scope` | `show` \| `study` \| `train` | the scope that version declared. | +| `accepted_at` | UTC timestamp | `origin_server_ts` of the first reply. | +| `event_id` | Matrix event id | that reply, as proof. | + +Key: `(experiment_id, member_id, notice_version)`. Written once, never updated. + +## Terms (newcomers only) + +Joining Heimdall is gated by terms. Acceptance is the terms version recorded by +the Matrix `m.login.terms` stage at registration: member field `terms_version` +(string) and `terms_accepted_at` (UTC timestamp). These fields belong to the +Heimdall member record. A typed `MemberTerms` record keyed by `member_id` is the +shape of that pair in a typed library; it is not a second store, and the +collector does not hold it. This applies to newcomers +only. Members who joined before the terms stage existed, and Minds, are not +re-asked and have no `terms_version`; the collector does not read it and does not +gate on it. The notice above is the only consent the collector itself checks. + +Open gap: the registration checkbox records no version. Void is raising this; +nothing here rules it. + +## Answer record + +One record per answer. Fields: + +| field | type | rule | +|---|---|---| +| `sample_id` | string `@` | names the thing answered about; `` follows the version rule under Representation. A changed sample is a new version. | +| `experiment_id` | string | the experiment that asked. | +| `member_id` | Heimdall member id | the respondent. Never a Matrix id, never a display name. | +| `notice_version` | integer | the notice version in force when the answer was received; matches an acceptance record by the key above. | +| `scope` | `show` \| `study` \| `train` | copied from that notice version at answer time. Never edited afterwards. | +| `text` | string, or absent | the answer as given. Non-empty while the answer is live; absent once exited. | +| `exited` | boolean, default false | one-way: set by redaction, never cleared. A new answer is a new record. | +| `text_hash` | 32 bytes, or absent | the tombstone hash of `text` (see "Tombstone"). Present only once exited, and only when the collector held `text` before the redaction. | +| `received_at` | UTC timestamp | assigned by the collector when it receives the reply. Not copied from the reply. | +| `event_id` | Matrix event id | the reply; the receipt. | + +Key: `(experiment_id, sample_id, member_id)`. A member's later answer to the +same key replaces the earlier one (latest `received_at` wins); the earlier text +is not retained. Two replies to one key with equal `received_at` tie-break on +`event_id` compared bytewise: the greater `event_id` wins as the later reply. + +Empty text: a live (non-exited) answer must have non-empty `text`, and the +collector rejects a reply whose text is empty. Empty text is valid only on an +exited answer, where redaction clears `text` and leaves the hash-only tombstone. + +A reply whose `notice_version` or `scope` is missing, unparseable or has no +matching acceptance record is stored, with those fields absent. It is excluded +from every use: no tally, no display, and from training in particular. It +writes no acceptance record. The reply is kept so the member can still exit it +and the collector can show what it refused, but no scope was declared, so none +is inferred. The notice version of a reply is the one the room's notice state +event held at the reply's `origin_server_ts`; if the collector cannot establish +what it held then, the reply has no notice version. + +### Representation + +- Timestamps are Unix milliseconds in UTC, stored as a signed 64-bit integer, + the unit of Matrix `origin_server_ts`. Every "UTC timestamp" above means this. +- `member_id`, `experiment_id` and `event_id` are non-empty strings; + `member_id` is opaque and `event_id` is the Matrix event id verbatim. +- Version rule. Every version field in the consent system is a positive integer + written in decimal with no leading zeros and at most 4294967295 (u32 max): + the `` of `sample_id` (`s@`), `notice_version`, the + `version` of the room notice `org.gamecult.notice`, and the notice acceptance + and terms records (`terms_version`, carried as that decimal string). Anything + else (zero, a sign, whitespace, a non-digit, a leading zero such as `s@01`, a + value above the maximum) is refused, not normalised: `s@1` is the only + spelling of version 1, so one version has exactly one key. A room notice + whose `version` fails the rule is malformed: no `train` line, narrowest scope + `show`. Nothing is silently rewritten to fit. +- Two encodings of the one version rule. In the `org.gamecult.notice` room state + event, `version` is a JSON number; a string (including `"01"`) is malformed. + In the answer key (`s@`, and the composite keys), the version stays + its encoded decimal text, held to the leading-zero refusal. The rule is the + same in both: a positive integer, no leading zeros, at most 4294967295. +- A composite key is its parts joined with U+001F. A part that is empty or + contains U+001F is refused, not escaped. + +## Versions + +Answers never tally across `sample_id` versions. A tally is computed for one +full `sample_id` including version; there is no "all versions" aggregate and no +API that takes a sample without a version. A version bump starts an empty tally. +Replies to an old version are dropped when the tally is for a newer one. + +## Training rule + +A record is training or evaluation data only if all hold: + +1. `scope = train`, copied from a notice version that declared `train`; +2. an acceptance record exists for `(experiment_id, member_id, notice_version)` + of the answer, its own `scope` is `train` and equals the answer's `scope`, + and `accepted_at <= received_at`, i.e. the author had accepted that notice + version when they answered; +3. `exited = false` at export time. + +Open gap: `accepted_at` is the homeserver's `origin_server_ts` and `received_at` is the collector's own clock, so +condition 2 compares two clocks. If the collector's clock is behind the homeserver's, a member's first answer (which is +also their acceptance) can fail `accepted_at <= received_at` and is excluded. That fails closed, never opens `train` +wrongly. The remedy is to compare on one clock (keep the reply's `origin_server_ts` on the answer); this revision does +not rule it. + +No recorded scope, or no recorded acceptance, means excluded. Records wider than +`show` are kept apart from `show` records: one store per scope (`show`, `study`, +`train`) plus a store for scopeless replies that no read path serves. A record +lives in the store its `scope` names, so a read for one level cannot return +another. A typed-record library need not model the partition; the collector's +store layout owns it. + +## Tombstone + +- Function: HMAC-SHA-256 (RFC 2104 with SHA-256, FIPS 180-4), output the raw 32 + bytes stored in `text_hash`. No truncation. +- Message: the ASCII string `heimdall.tombstone.v1`, U+001F, `event_id` + (UTF-8), U+001F, `text` (UTF-8, as received, no normalisation). The `event_id` + makes the hash specific to the reply, so equal texts hash differently. +- Key (the salt): 32 bytes from a cryptographically secure random source, + generated once per member by the member-side service, which is the service that + already maps Matrix sender to `member_id`. It is held there, beside the member + record, and is never given to the collector, a researcher, an export or a log. + The collector cannot compute or check a tombstone hash on its own. +- Redaction: when the collector sees a redaction of a reply whose text it holds, + it sends `event_id` and `text` to the member-side service, which returns the + hash; the collector writes `text_hash`, sets `exited`, and erases `text`. If the + service does not answer, `text` is kept, the record is not yet exited, and the + run retries. A reply the collector never collected has no text and gets no + tombstone. +- Verification: only the member-side service can check a claimed text against a + tombstone. If a member's salt is destroyed, their tombstones can no longer be + tied to any text, and that is the member's erasure. + +## Identity: count once, reward without exposure + +The Matrix sender is mapped to a Heimdall member id by Heimdall's linked +identity, at collection time. The collector stores `member_id` only; the Matrix +sender is discarded after mapping. A sender with no linked member is dropped, +not stored under a placeholder. + +- Count once: the key above makes one member one answer per sample version, + however many Matrix accounts or bridges they use. +- Reward without exposure: a reward is issued against `member_id` by the + rewarding service. Researchers and displays see counts and text under their + scope, never `member_id`. The mapping from a record to a person is available + to the reward path only. + +## Eureka's shape (Matrix) + +- One room per experiment. The room is `experiment_id`. +- The sample is posted as a message; the room's notice is its `org.gamecult.notice` state event. An answer + is a thread reply to the sample, the answer text being the body. There is no + per-reply scope syntax. +- The reply event is the receipt: its event id and timestamp are the proof the + answer was received. `received_at` is the collector's own clock at receipt, + not the reply's `origin_server_ts`. +- Redaction is exit (see below): redacting the reply event sets `exited`. +- The collector is on demand (it reads the room when run, it is not a standing + listener). It drops: + - redacted replies (recorded as exited); + - replies from unlinked senders; + - replies to an old sample version when the tally is for a newer one. + +## What Matrix cannot guarantee + +- **Receipt outliving a deleted reply.** Redaction removes content, and + homeserver retention varies. If the collector holds a receipt for a reply that + later disappears, it keeps a tombstone: `event_id`, `sample_id`, `received_at`, + and a salted hash of `text`. Hash only, never text, and no `member_id` + beyond what the record's key already holds. The count and the exit are + provable without the content. The record is marked `exited`; `text` is erased. + The tombstone is the exited `Answer` record itself, with `text_hash` set; it is + not a separate record. +- **Edits.** An edit is a new event, not the receipt. Only the original event + counts; edits are ignored, so what was accepted is what was received. To + change an answer, redact and reply again. +- **Bridge spoofing.** A bridge can post as any ghost user. Only senders whose + Matrix id resolves through Heimdall's linked identity to a member count; + bridged ghost ids are not linked identities. A bridge cannot raise scope: scope + comes only from the room's notice state event. +- **Bridged origin.** Events that arrive through a bridge are dropped by origin, + not by judging their text. + +## What the respondent sees + +Posted with the sample, in the room: + +- the notice: its scope in one line, that the first reply accepts it, and how to + exit; +- the notice version. + +After answering, the respondent sees their receipt: the thread reply itself, +plus the collector's acknowledgement that names the notice version and scope it +recorded. + +The consent and vote controls make no reward sound, animation or other +reinforcement. A reward is issued later, by the rewarding service, never as +feedback to the act of consenting or voting. + +## How to exit + +Redact your reply. That is all. The next collection run sets `exited`, erases +`text`, and excludes the record from every later tally and from training. It +cannot undo past use: tallies already shown and anything already exported +cannot be recalled by Matrix or by the collector. The experiment's page states +the export cadence so the window is known.