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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-06 14:31:45 +00:00
commit d836f29e3a

View file

@ -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 (`<sample>@<version>`). 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 `<sample>@<version>` | names the thing answered about; `<version>` 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: <x>` 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.