From e8edd88efc817a27ca3428c74eaadf4c13eae2dd Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 13:30:45 +0000 Subject: [PATCH 1/8] Specify answer collector record and consent Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/answer-collector-consent.md diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md new file mode 100644 index 0000000..f02be6a --- /dev/null +++ b/docs/answer-collector-consent.md @@ -0,0 +1,118 @@ +# 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. + +Scope: free-text and choice answers that members give to a sample put to them +by an experiment. Not identity proof (see `architecture.md`). + +## Record + +One record per answer. Fields: + +| field | rule | +|---|---| +| `sample_id` | names the thing answered about, with a version component (`@`). A changed sample is a new version. | +| `experiment_id` | the experiment that asked. | +| `member_id` | Heimdall member id of the respondent. Never a Matrix id, never a display name. | +| `scope` | `show` \| `study` \| `train`. Default `show`. | +| `text` | the answer as given. | +| `revoked` | boolean, default false. One-way: a revoked record is never un-revoked; a new answer is a new record. | +| `received_at` | collector clock, UTC. | + +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. + +## 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. + +## Scope + +Scope is the widest use the respondent allowed. Each use reads exactly one +level: + +- `show`: may be displayed back inside the experiment (to the respondent, and + in aggregate to participants). Nothing else. +- `study`: `show`, plus analysis by the experiment's researchers. May be quoted + in findings only unattributed. +- `train`: `study`, plus use as training or evaluation data for models. + +Rule: nothing is training data unless `scope = train`. A missing, unparseable +or unknown scope is `show`, never wider. Scope can be narrowed by a new answer +or revocation; widening is a new answer by the respondent, never an inference. + +## 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; an answer is a thread reply to it whose + body starts `scope: ` on its first line, the answer text following. +- The reply event is the receipt: its event id and timestamp are the proof the + answer was received, and `received_at` comes from it. +- Redaction is revoke: redacting the reply event sets `revoked`. +- The collector is on demand (it reads the room when run, it is not a standing + listener). It drops: + - redacted replies; + - replies with no `scope:` line (consent is explicit in Matrix; the `show` + default applies to records created elsewhere, not to silence); + - replies from unlinked senders; + - replies to an old sample version when the tally is for a newer one. +- Records wider than `show` (`study`, `train`) are kept apart from `show` + records: separate store or partition, so a read for one level cannot + accidentally return another. + +## 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 hash of `text` (hash only, no text), so the count and the revoke are + provable without the content. The record is marked `revoked`; `text` is + erased. +- **Edits.** An edit is a new event, not the receipt. Only the original event + counts; edits are ignored, so what was consented to 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, as + scope comes only from the reply's own `scope:` line. +- **Discord material.** Nothing that originates from Discord is collected, + even through a bridge, at any scope: Discord forbids training on its + content. Bridged events are dropped by origin, not by judging text. + +## What the respondent sees + +Posted with the sample, in the room: + +- the three scopes in one line each, as above; +- that no `scope:` line means the answer is not collected; +- how to revoke. + +After answering, the respondent sees their receipt: the thread reply itself, +plus the collector's acknowledgement that names the scope it recorded. If it +recorded narrower than they asked, it says so. + +## How to revoke + +Redact your reply. That is all. The next collection run marks the record +`revoked`, erases `text`, and excludes it from every tally and export. Anything +already exported at `train` before the run cannot be recalled by Matrix; the +experiment's page states the export cadence so the window is known. -- 2.47.3 From d836f29e3a7b8e8b2b519a09ed6c9aecdb5d0dfb Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 14:31:45 +0000 Subject: [PATCH 2/8] Align answer-collector consent with #swarm rulings of 2026-11-06 Room notice declares scope (train included); first reply accepts a notice version; terms version for newcomers only; training needs an acceptance at answer time and a recorded scope; redaction is exit, past use not undone; salted hash-only tombstone; no reward sound on consent controls; personal details out of scope; typed field tables for the CultCache records. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 158 +++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 52 deletions(-) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index f02be6a..129f279 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -2,49 +2,103 @@ 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. +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`). +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. -## Record +## Notice + +Consent is declared once per room, not per reply. Each experiment room carries +one notice line, pinned with the sample, 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. + +| 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 pinned. 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 pinned 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). 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. + +## Answer record One record per answer. Fields: -| field | rule | -|---|---| -| `sample_id` | names the thing answered about, with a version component (`@`). A changed sample is a new version. | -| `experiment_id` | the experiment that asked. | -| `member_id` | Heimdall member id of the respondent. Never a Matrix id, never a display name. | -| `scope` | `show` \| `study` \| `train`. Default `show`. | -| `text` | the answer as given. | -| `revoked` | boolean, default false. One-way: a revoked record is never un-revoked; a new answer is a new record. | -| `received_at` | collector clock, UTC. | +| field | type | rule | +|---|---|---| +| `sample_id` | string `@` | names the thing answered about; `` is a positive integer. 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. Absent once exited. | +| `exited` | boolean, default false | one-way: set by redaction, never cleared. A new answer is a new record. | +| `received_at` | UTC timestamp | `origin_server_ts` of 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. +A record whose `notice_version` or `scope` is missing, unparseable or has no +matching acceptance record is not collected. It is excluded from every use, and +from training in particular. + ## 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. -## Scope +## Training rule -Scope is the widest use the respondent allowed. Each use reads exactly one -level: +A record is training or evaluation data only if all hold: -- `show`: may be displayed back inside the experiment (to the respondent, and - in aggregate to participants). Nothing else. -- `study`: `show`, plus analysis by the experiment's researchers. May be quoted - in findings only unattributed. -- `train`: `study`, plus use as training or evaluation data for models. +1. `scope = train`, copied from a notice version that declared `train`; +2. an acceptance record exists for `(experiment_id, member_id, notice_version)` + with `accepted_at <= received_at`, i.e. the author had accepted that notice + version when they answered; +3. `exited = false` at export time. -Rule: nothing is training data unless `scope = train`. A missing, unparseable -or unknown scope is `show`, never wider. Scope can be narrowed by a new answer -or revocation; widening is a new answer by the respondent, never an inference. +No recorded scope, or no recorded acceptance, means excluded. Records wider than +`show` are kept apart from `show` records: separate store or partition, so a read +for one level cannot return another. ## Identity: count once, reward without exposure @@ -63,56 +117,56 @@ not stored under a placeholder. ## Eureka's shape (Matrix) - One room per experiment. The room is `experiment_id`. -- The sample is posted as a message; an answer is a thread reply to it whose - body starts `scope: ` on its first line, the answer text following. +- The sample is posted as a message, with the notice pinned beside it. 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, and `received_at` comes from it. -- Redaction is revoke: redacting the reply event sets `revoked`. +- 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; - - replies with no `scope:` line (consent is explicit in Matrix; the `show` - default applies to records created elsewhere, not to silence); + - redacted replies (recorded as exited); - replies from unlinked senders; - replies to an old sample version when the tally is for a newer one. -- Records wider than `show` (`study`, `train`) are kept apart from `show` - records: separate store or partition, so a read for one level cannot - accidentally return another. ## 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 hash of `text` (hash only, no text), so the count and the revoke are - provable without the content. The record is marked `revoked`; `text` is - erased. + 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. - **Edits.** An edit is a new event, not the receipt. Only the original event - counts; edits are ignored, so what was consented to is what was received. To + 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, as - scope comes only from the reply's own `scope:` line. -- **Discord material.** Nothing that originates from Discord is collected, - even through a bridge, at any scope: Discord forbids training on its - content. Bridged events are dropped by origin, not by judging text. + bridged ghost ids are not linked identities. A bridge cannot raise scope: scope + comes only from the pinned notice version. +- **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 three scopes in one line each, as above; -- that no `scope:` line means the answer is not collected; -- how to revoke. +- 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 scope it recorded. If it -recorded narrower than they asked, it says so. +plus the collector's acknowledgement that names the notice version and scope it +recorded. -## How to revoke +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. -Redact your reply. That is all. The next collection run marks the record -`revoked`, erases `text`, and excludes it from every tally and export. Anything -already exported at `train` before the run cannot be recalled by Matrix; the -experiment's page states the export cadence so the window is known. +## 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. -- 2.47.3 From 2364979f0d2bb5a29f68f06ed2fac1cd998199c5 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 17:24:39 +0000 Subject: [PATCH 3/8] Rule the open points from the CultLib consent-records review Scopeless replies are stored and excluded; tombstone is HMAC-SHA-256 with a member-side salt; representation, training-rule scope match, scope partition and MemberTerms ownership fixed. Docs only. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 62 ++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index 129f279..fe0a321 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -50,7 +50,10 @@ Key: `(experiment_id, member_id, notice_version)`. Written once, never updated. 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). This applies to newcomers +(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. @@ -68,6 +71,7 @@ One record per answer. Fields: | `scope` | `show` \| `study` \| `train` | copied from that notice version at answer time. Never edited afterwards. | | `text` | string, or absent | the answer as given. 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 | `origin_server_ts` of the reply. | | `event_id` | Matrix event id | the reply; the receipt. | @@ -75,9 +79,25 @@ 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. -A record whose `notice_version` or `scope` is missing, unparseable or has no -matching acceptance record is not collected. It is excluded from every use, and -from training in particular. +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 pinned at the reply's +`origin_server_ts`; if the collector cannot establish what was pinned 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`, `event_id` and `terms_version` are non-empty + strings; `member_id` is opaque and `event_id` is the Matrix event id verbatim. + `notice_version` and the `` of `sample_id` are positive integers + (unsigned 32-bit). +- A composite key is its parts joined with U+001F. A part that is empty or + contains U+001F is refused, not escaped. ## Versions @@ -92,13 +112,39 @@ 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)` - with `accepted_at <= received_at`, i.e. the author had accepted that notice + 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. No recorded scope, or no recorded acceptance, means excluded. Records wider than -`show` are kept apart from `show` records: separate store or partition, so a read -for one level cannot return another. +`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 @@ -137,6 +183,8 @@ not stored under a placeholder. 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. -- 2.47.3 From 4e99bfa6286895f884c53876567e3ef449866c48 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 19:40:44 +0000 Subject: [PATCH 4/8] Rule empty text and received_at ties from CultLib #12 follow-up Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index fe0a321..accce7a 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -69,15 +69,20 @@ One record per answer. Fields: | `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. Absent once exited. | +| `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 | `origin_server_ts` of the reply. | +| `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. +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 @@ -167,7 +172,8 @@ not stored under a placeholder. 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, and `received_at` comes from it. + 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: -- 2.47.3 From cafdc52e5ffbae1a5be9db1343c73240d6f98959 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 23:28:40 +0000 Subject: [PATCH 5/8] Rule version spelling and the room notice event Leading-zero versions are refused, not normalised; one version, one key. A room's notice is the org.gamecult.notice state event {version, scope} under the room's power levels; a pinned message is not accepted. Absent or malformed means no train line and the narrowest scope. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 42 +++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index accce7a..b75e171 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -15,9 +15,26 @@ path. ## Notice Consent is declared once per room, not per reply. Each experiment room carries -one notice line, pinned with the sample, 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. +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` is a positive integer written in decimal with no leading zeros. +- `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` that is not a + positive integer or has leading zeros, 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 | |---|---| @@ -30,7 +47,7 @@ its wording or scope is a new version. reply. The first reply a member posts in the room is their acceptance of the notice -version then pinned. No separate click, command or form. A later notice version +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 @@ -39,7 +56,7 @@ needs a new acceptance: the member's first reply under it. |---|---|---| | `member_id` | Heimdall member id | the accepting member. | | `experiment_id` | string | the room. | -| `notice_version` | integer | the version pinned when the reply was received. | +| `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. | @@ -64,7 +81,7 @@ One record per answer. Fields: | field | type | rule | |---|---|---| -| `sample_id` | string `@` | names the thing answered about; `` is a positive integer. A changed sample is a new version. | +| `sample_id` | string `@` | names the thing answered about; `` is a positive integer in decimal with no leading zeros. 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. | @@ -89,9 +106,9 @@ 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 pinned at the reply's -`origin_server_ts`; if the collector cannot establish what was pinned then, the -reply has no notice version. +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 @@ -101,6 +118,9 @@ reply has no notice version. strings; `member_id` is opaque and `event_id` is the Matrix event id verbatim. `notice_version` and the `` of `sample_id` are positive integers (unsigned 32-bit). +- A `` with leading zeros (`s@01`) is refused, not normalised: `s@1` is + the only spelling of version 1, so one version has exactly one key. The same + holds for `notice_version`. Nothing is silently rewritten to fit. - A composite key is its parts joined with U+001F. A part that is empty or contains U+001F is refused, not escaped. @@ -168,7 +188,7 @@ not stored under a placeholder. ## Eureka's shape (Matrix) - One room per experiment. The room is `experiment_id`. -- The sample is posted as a message, with the notice pinned beside it. An answer +- 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 @@ -197,7 +217,7 @@ not stored under a placeholder. - **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 pinned notice version. + 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. -- 2.47.3 From d6a8d4238cd4cf13845dd80107d9f393e1924da1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 00:30:00 +0000 Subject: [PATCH 6/8] Rule every consent version as a positive u32 with no leading zeros Fold the leading-zero text into one version rule under Representation: sample, notice, notice acceptance and terms versions, and the room notice, all positive decimal integers, no leading zeros, at most 4294967295. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index b75e171..6dc29ca 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -24,12 +24,12 @@ version. A room's notice is a Matrix room state event: type `org.gamecult.notice`, empty `state_key`, content `{version, scope}`. -- `version` is a positive integer written in decimal with no leading zeros. +- `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` that is not a - positive integer or has leading zeros, an unknown `scope`) means no `train` +- 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 @@ -81,7 +81,7 @@ One record per answer. Fields: | field | type | rule | |---|---|---| -| `sample_id` | string `@` | names the thing answered about; `` is a positive integer in decimal with no leading zeros. A changed sample is a new version. | +| `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. | @@ -114,13 +114,18 @@ what it held then, the reply has no notice version. - 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`, `event_id` and `terms_version` are non-empty - strings; `member_id` is opaque and `event_id` is the Matrix event id verbatim. - `notice_version` and the `` of `sample_id` are positive integers - (unsigned 32-bit). -- A `` with leading zeros (`s@01`) is refused, not normalised: `s@1` is - the only spelling of version 1, so one version has exactly one key. The same - holds for `notice_version`. Nothing is silently rewritten to fit. +- `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. - A composite key is its parts joined with U+001F. A part that is empty or contains U+001F is refused, not escaped. -- 2.47.3 From aaeb175dc666e81a13dc2890da26898eaa76cb60 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 02:34:48 +0000 Subject: [PATCH 7/8] State the two encodings of the version rule; note the checkbox version gap Room notice version is a JSON number (a string, including "01", is malformed); the answer key keeps the version as encoded text under the leading-zero refusal. Same rule, two encodings. The registration checkbox records no version: recorded as an open gap Void is raising. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index 6dc29ca..89f34f1 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -75,6 +75,9 @@ 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: @@ -126,6 +129,11 @@ what it held then, the reply has no notice version. 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. -- 2.47.3 From 570e930043b154d1ab03350643a2b0198fd3489a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 10 Nov 2026 17:12:15 +0000 Subject: [PATCH 8/8] Note the two-clock comparison in the training rule as an open gap accepted_at is origin_server_ts, received_at is the collector's clock; a collector clock behind the homeserver's excludes a first answer. Fails closed. Co-Authored-By: Claude Sonnet 5.5 --- docs/answer-collector-consent.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/answer-collector-consent.md b/docs/answer-collector-consent.md index 89f34f1..ca8175e 100644 --- a/docs/answer-collector-consent.md +++ b/docs/answer-collector-consent.md @@ -155,6 +155,12 @@ A record is training or evaluation data only if all hold: 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 -- 2.47.3