Fork card: decider_count, answerer_count, nullable answerer (reader views) #117
7 changed files with 4390 additions and 1565 deletions
File diff suppressed because one or more lines are too long
|
|
@ -26,13 +26,15 @@ All documents are CultNet map-mode: every key written, nil where absent, closed
|
|||
|
||||
### card
|
||||
|
||||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, raised_at, deciders[], status, ruling, ruled_from[], order, options_hash}`.
|
||||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, raised_at, deciders[], decider_count, status, ruling, ruled_from[], order, options_hash}`.
|
||||
|
||||
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
|
||||
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
|
||||
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `raised_at` (the instant the question was first admitted, an RFC3339 instant string as `answered_at`, or nil when the mind holds no admission time, as for a card projected before the field; the key is always written; it never moves when the question is edited; age is the client's: now minus `raised_at`), `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
|
||||
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `raised_at` (the instant the question was first admitted, an RFC3339 instant string as `answered_at`, or nil when the mind holds no admission time, as for a card projected before the field; the key is always written; it never moves when the question is edited; age is the client's: now minus `raised_at`), `deciders`, `decider_count` (the number of deciders of the call, set beside `deciders`; neither it nor the emptiness of `deciders` enters the hash, so the hash a non-decider recomputes is a decider's), `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The per-call list holds one to 64 ids** (Huginn's bound; vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. **`decider_count` is the size of that list, an integer 1 to 64, always written** (vectors `decider_count_missing`, `_zero`, `_65`, `_string`, `_null`). A fork with no decider is still refused, now as `decider_count` 0. **`deciders` holds that list for a decider and is empty for a reader who is not one: empty means "the ids are withheld from this reader", never "no deciders"** (vector `withheld_deciders`). Rule R-card: when `deciders` is non-empty it holds exactly `decider_count` ids; a visible list is whole (vectors `decider_count_fewer_than_the_ids`, `decider_count_more_than_the_ids_shown`). A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members; R-card (above); R-answer-result: `answer_result.answer.answerer` is never nil, being the sender's own stamped account (vector `answer_result_with_a_nil_answerer`); R-record, R-entry and R-list ("Reader views" below).
|
||||
|
||||
**Reader views.** A card and its record are shown in one of two views. The **decider view** carries the ids: `deciders` whole, every `answerer` named, and the counts equal to the ids. The **non-decider view** withholds them: `deciders` empty, every `answerer` nil, the counts kept (`decider_count`; `answerer_count` on the record). Who is a decider is Huginn's decision, not this contract's: a reader is a decider exactly when the call's subject is byte-equal to one of the call's deciders (`eureka-substrate:ruling:desk-read-visibility-withheld-ids`); Huginn's door withholds, Bifrost cannot (the card is opaque to it). There is no viewer flag on any document: the view is read off whether `deciders` is empty. This contract carries both views and refuses a view that leaks: R-record: if every `answerer` is named, `answerer_count` is the number of distinct ids; if all are nil and there is at least one answer, `answerer_count` is between 1 and the number of answers; with no answers it is 0 (vectors `answerer_count_disagrees_with_the_ids`, `answerer_count_counts_answers_not_people`, `withheld_count_over_the_answers`, `withheld_count_zero_over_answers`, `withheld_count_over_no_answers`); R-entry, the leak rule: an entry with `deciders` non-empty names every answerer, an entry with `deciders` empty names none (vectors `decider_card_with_withheld_answers`, `withheld_card_with_named_answers`, `withheld_card_with_a_named_answerer_among_nils`); R-list: all entries of one page agree on the view (vector `a_page_mixes_views`). The view does not move the page: `non_decider_page` has the counts, order, cursor and stamp of the decider page it twins, and the vectors' no-leak check finds no id string in a non-decider vector's bytes. `options_hash` is the same in both views, and the bound-answer and `stale_fork` machinery is untouched.
|
||||
|
||||
### Options hash (canonical bytes)
|
||||
|
||||
|
|
@ -65,7 +67,9 @@ The stamped `authority` field of a stored answer is an **attribution**: the role
|
|||
|
||||
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
|
||||
|
||||
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
|
||||
**The record.** `record` is `{schemaVersion, fork_id, options_hash, counted_answer_id, answerer_count, answers[]}`. `answerer_count` is an integer of at least 0: the number of distinct answerer ids over every stored answer of the fork, refusals included (a refusal stores the stamped answerer too), and it is the same number in both views (vectors `answerer_count_missing`, `_negative`, `_null`). `answers[].answerer` is a string or nil (nil only in the non-decider view).
|
||||
|
||||
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. Huginn's stored `desk_answer` keeps a non-nil `answerer`; nil exists only in a record projected to a non-decider. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
|
||||
|
||||
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
|
||||
|
||||
|
|
@ -113,8 +117,8 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp
|
|||
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
|
||||
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
|
||||
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
|
||||
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list.
|
||||
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
|
||||
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome (a non-decider sees no account id, "Reader views"), and which forks a caller may answer is the decider list.
|
||||
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call for a decider and empty for a reader who is not one, with `decider_count` travelling with both views, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
|
||||
|
||||
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
|
||||
|
||||
|
|
@ -122,7 +126,7 @@ Calls 1 and 2 are enough for a read-only desk. Every response is the pinned Mess
|
|||
|
||||
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c<order of the last entry returned>` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order`, for example `fork_too_large: fork <fork_id> (order <n>) alone is past the response budget`. **A refusal's `error` text is informational and not pinned**: the contract pins the code, the HTTP status, that the text names the fork id (and the order on a list), and the `c<order>` continuation on a list; the vectors' wording is a sample, and the judges check those four things, not the bytes of the text. The client continues the list with cursor `c<n>`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c<n>` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
|
||||
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork <fork_id> alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Huginn withholds account ids from a reader who is not a decider and returns counts ("Reader views"); Bifrost still strips notes.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork <fork_id> alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
|
||||
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
|
||||
|
||||
|
|
@ -151,6 +155,7 @@ The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a
|
|||
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
|
||||
| none | the question's admission time (no timestamp in the old format; `asked_on` is a date and stays uncarried) | `raised_at`, an instant or nil, not hashed |
|
||||
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
|
||||
| none | none | `decider_count`, `answerer_count` (counts that stand in for withheld ids) |
|
||||
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
|
||||
| `note`: her words verbatim | | `answer.note`, verbatim |
|
||||
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
|
||||
|
|
@ -180,6 +185,7 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
|
|||
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
|
||||
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
|
||||
- `raised_at` (2026-11-11) is the desk-feed addition (Bifrost PR 67): a required key, an instant or nil, outside `options_hash` (the five hashed members and every digest are unchanged; vectors `raised_at_nil`, `raised_at_epoch_integer`, `raised_at_date_only`, `raised_at_missing`). The card is spec-authored and no runtime deploys it, so the schema id stays `gamecult.fork.card.v0`.
|
||||
- Reader views (2026-11-11) are the desk-feed addition behind Huginn PR 56 and `eureka-substrate:ruling:desk-read-visibility-withheld-ids`: the card gains `decider_count` and `deciders` may be empty; the record gains `answerer_count` and `answerer` may be nil, the counts beside the collections they summarise (not on the list envelope, which neither the read entry nor the `stale_fork` card travels in). `options_hash` and every digest are unchanged. The schema ids stay `gamecult.fork.card.v0` and `gamecult.fork.record.v0` for the same reason as `raised_at`.
|
||||
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
|
||||
|
||||
## Open choices
|
||||
|
|
@ -197,3 +203,4 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
|
|||
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
|
||||
12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it.
|
||||
13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. The same holds for the answer route: a `stale_fork` answer whose card re-read is past the cap is a Bifrost target too (`fork_too_large` 422 over a stored `Refused(stale_fork)`), and until it is built the client sees 503 `unavailable`.
|
||||
14. What a non-decider can still infer: the card's `status` and `order`, the `authority` role on each answer (a role, not an id, and the ruling leaves it whole), and `answerer_count`. Whether a role plus a count is too much to show is Eureka's ruling to revisit.
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"raised_in",
|
||||
"raised_at",
|
||||
"deciders",
|
||||
"decider_count",
|
||||
"status",
|
||||
"ruling",
|
||||
"ruled_from",
|
||||
|
|
@ -156,13 +157,18 @@
|
|||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 64
|
||||
},
|
||||
"decider_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 64
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"open",
|
||||
|
|
@ -384,7 +390,14 @@
|
|||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
|
|
@ -470,7 +483,14 @@
|
|||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
|
|
@ -542,7 +562,14 @@
|
|||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
|
|
@ -642,7 +669,14 @@
|
|||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
|
|
@ -746,6 +780,7 @@
|
|||
"fork_id",
|
||||
"options_hash",
|
||||
"counted_answer_id",
|
||||
"answerer_count",
|
||||
"answers"
|
||||
],
|
||||
"properties": {
|
||||
|
|
@ -768,6 +803,10 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"answerer_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
|
|||
### Added
|
||||
|
||||
- Fork card (`contracts/cultnet/governance/fork-card.md`): `raised_at`, a required key holding an RFC3339 instant string or nil (the question's first admission time), outside `options_hash`; the card vectors are re-pinned with the key, three negatives (epoch integer, date-only string, missing key) and a nil-`raised_at` valid card; every `optionsHash` digest is unchanged.
|
||||
- Fork card reader views (`fork-card.md`, "Reader views"): the card gains `decider_count` (required, 1 to 64) and `deciders` may be empty (ids withheld from a non-decider); the record gains `answerer_count` (distinct answerers over every stored answer, refusals included); `answerer` is nullable inside a record shown to a non-decider. Card and record vectors are re-pinned (card map header `DE0011` to `DE0012`, record `85` to `86`); withheld twins, the R-card, R-record, R-entry, R-list and R-answer-result rule vectors and a no-leak byte check are new; every `optionsHash` and `bodyDigests` digest is unchanged.
|
||||
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
|
||||
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
|
||||
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const jstr = (s: string): string => {
|
|||
};
|
||||
type Option = { id: string; text: string };
|
||||
type Shown = { title: string; context: string; options: Option[]; recommendation: string; depends: string[] };
|
||||
type Card = Shown & { fork_id: string; leans: string | null; raised_at: string | null; deciders: string[]; status: string; ruling: string | null; ruled_from: string[]; options_hash: string };
|
||||
type Card = Shown & { fork_id: string; leans: string | null; raised_at: string | null; deciders: string[]; decider_count: number; status: string; ruling: string | null; ruled_from: string[]; options_hash: string };
|
||||
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
|
||||
const nfc = (s: string): string => s.normalize("NFC");
|
||||
const canonical = (c: Shown): Buffer =>
|
||||
|
|
@ -104,6 +104,7 @@ const cardRuleErrors = (c: Card): string[] => {
|
|||
if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option");
|
||||
if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option");
|
||||
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
|
||||
if (c.deciders.length > 0 && c.deciders.length !== c.decider_count) errors.push("R-card: shown deciders are not exactly decider_count ids");
|
||||
if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes");
|
||||
if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling");
|
||||
if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling");
|
||||
|
|
@ -126,6 +127,10 @@ test("fork card: options hash is SHA-256 of the canonical JSON, computed here, a
|
|||
const stamped: Card = { ...card0, raised_at };
|
||||
assert.equal(optionsHash(stamped), card0.options_hash, "raised_at stays outside the hash");
|
||||
}
|
||||
for (const [deciders, decider_count] of [[[], 2], [card0.deciders, 64]] as const) {
|
||||
const shown: Card = { ...card0, deciders: [...deciders], decider_count };
|
||||
assert.equal(optionsHash(shown), card0.options_hash, "decider_count and the emptiness of deciders stay outside the hash");
|
||||
}
|
||||
assert.equal(byName.get("heimdall_E_decomposed_equals_D"), byName.get("heimdall_D_nfc_escapes_astral"), "an NFD and an NFC spelling hash alike");
|
||||
for (const moved of ["heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied", "option_text_changed",
|
||||
"title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed"]) {
|
||||
|
|
@ -176,7 +181,7 @@ test("fork card: mutating any shown member, an option id included, changes the h
|
|||
assert.notEqual(optionsHash(copy), base, `mutation ${i}`);
|
||||
}
|
||||
const hidden = structuredClone(card);
|
||||
hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = ["x"]; hidden.status = "closed"; hidden.ruled_from = ["n1"];
|
||||
hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = []; hidden.decider_count = 7; hidden.status = "closed"; hidden.ruled_from = ["n1"];
|
||||
assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash");
|
||||
});
|
||||
|
||||
|
|
@ -200,6 +205,11 @@ test("fork card: documents validate and encode to the vector bytes", () => {
|
|||
assert.ok(check(forkSchema, "answerResult", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.equal(v.message.card !== null, v.name === "stale_fork", "only a stale refusal shows the new fork");
|
||||
assert.deepEqual(resultRuleErrors(v.message), [], v.name);
|
||||
}
|
||||
for (const v of fork.resultsInvalid) {
|
||||
assert.ok(!check(forkSchema, "answerResult", v.message) || resultRuleErrors(v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const v of fork.unavailable) {
|
||||
assert.ok(check(forkSchema, "unavailable", v.message), v.name);
|
||||
|
|
@ -228,12 +238,16 @@ test("fork card: every negative is refused by the schema or a stated rule", () =
|
|||
}
|
||||
});
|
||||
|
||||
// R-answer-result: the answer in an answer_result is the sender's own, stamped with their account, never nil (nil exists only in a record shown to a non-decider).
|
||||
const resultRuleErrors = (r: { answer: { answerer: string | null } }): string[] => (r.answer.answerer === null ? ["R-answer-result: the answerer is nil"] : []);
|
||||
|
||||
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
|
||||
const noteRuleErrors = (def: string, message: any): string[] => {
|
||||
const note = def === "answerRequest" ? message?.answer?.note : def === "deskAnswer" ? message?.note : null;
|
||||
return note != null && Buffer.byteLength(note, "utf8") > 4000 ? ["note is over 4000 UTF-8 bytes"] : [];
|
||||
};
|
||||
|
||||
const OP_ID = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
|
||||
const TAILS: Record<string, string> = {
|
||||
unauthenticated: "a verified session is required",
|
||||
body_forbidden_field: "answerer and authority are set by the receiver",
|
||||
|
|
@ -305,7 +319,7 @@ function judge(start: Card, steps: { kind: string; body: Record<string, any> | n
|
|||
}
|
||||
responses.push(down && reached ? unavailable() : response);
|
||||
}
|
||||
return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answers } };
|
||||
return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answerer_count: new Set(answers.map((a) => a.answerer)).size, answers } };
|
||||
}
|
||||
|
||||
test("fork card: every scenario replays to the vector's responses and record, bytes included", () => {
|
||||
|
|
@ -317,6 +331,7 @@ test("fork card: every scenario replays to the vector's responses and record, by
|
|||
assert.deepEqual(record, sc.record, sc.name);
|
||||
assert.ok(check(forkSchema, "record", record), sc.name);
|
||||
assert.equal(hexOf(record), sc.recordHex, sc.name);
|
||||
for (const s of sc.steps) if (s.response?.answer) assert.deepEqual(resultRuleErrors(s.response), [], sc.name);
|
||||
for (const s of sc.steps) if (s.response) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name);
|
||||
}
|
||||
});
|
||||
|
|
@ -383,15 +398,34 @@ test("fork card: operator authority needs a cited Counted answer record", () =>
|
|||
});
|
||||
|
||||
// ---- transport: list, read, auth (fork-card.md "Transport") ----
|
||||
const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answers: Record<string, any>[] } }): string[] => {
|
||||
type Entry = { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answerer_count: number; answers: Record<string, any>[] } };
|
||||
const entryRuleErrors = (e: Entry): string[] => {
|
||||
const errors = cardRuleErrors(e.card);
|
||||
if (e.record.fork_id !== e.card.fork_id) errors.push("record of another fork");
|
||||
if (e.record.options_hash !== e.card.options_hash) errors.push("record under another hash");
|
||||
const counted = e.record.answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === e.card.options_hash
|
||||
&& !e.record.answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
|
||||
if ((counted?.answer_id ?? null) !== e.record.counted_answer_id) errors.push("counted_answer_id is not derived");
|
||||
// R-record: named answerers are counted by distinct id, nil ones by a bound
|
||||
const named = e.record.answers.filter((a) => a.answerer !== null).length, total = e.record.answers.length, n = e.record.answerer_count;
|
||||
if (total === 0 && n !== 0) errors.push("R-record: no answers and a count other than 0");
|
||||
else if (named === total && total > 0 && n !== new Set(e.record.answers.map((a) => a.answerer)).size) errors.push("R-record: answerer_count is not the distinct answerers");
|
||||
else if (named === 0 && total > 0 && (n < 1 || n > total)) errors.push("R-record: a withheld count outside 1..answers");
|
||||
// R-entry: the card's view and the record's view agree, so a withheld card never rides beside a named account
|
||||
if (e.card.deciders.length > 0 && named < total) errors.push("R-entry: a decider-view card with withheld answerers");
|
||||
if (e.card.deciders.length === 0 && named > 0) errors.push("R-entry: a withheld card beside a named answerer");
|
||||
return errors;
|
||||
};
|
||||
// R-list: all entries of one page are one reader's view
|
||||
const viewRuleErrors = (forks: { card: Card }[]): string[] => (new Set(forks.map((f) => f.card.deciders.length > 0)).size > 1 ? ["R-list: a page mixes views"] : []);
|
||||
// The non-decider twin of a decider-view entry: ids withheld, counts kept.
|
||||
const withheld = (e: Entry): Entry => ({ ...e, card: { ...e.card, deciders: [] }, record: { ...e.record, answers: e.record.answers.map((a) => ({ ...a, answerer: null })) } });
|
||||
const idsOf = (e: Entry): string[] => [...e.card.deciders, ...e.record.answers.map((a) => a.answerer as string)];
|
||||
const bytesHaveNoId = (hex: string, ids: string[], name: string): void => {
|
||||
const bytes = Buffer.from(hex, "hex");
|
||||
assert.ok(ids.length > 0, name);
|
||||
for (const id of ids) assert.ok(!bytes.includes(Buffer.from(id, "utf8")), `${name}: the withheld bytes carry ${id}`);
|
||||
};
|
||||
|
||||
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
|
||||
const orderErrors = (cards: { fork_id: string; order: number }[]): string[] => {
|
||||
|
|
@ -470,6 +504,13 @@ test("fork transport: requests and entries validate and encode to the vector byt
|
|||
for (const v of t[section].valid) {
|
||||
assert.ok(check(forkSchema, def, v.message), v.name);
|
||||
if (def === "entry") assert.deepEqual(entryRuleErrors(v.message), [], v.name);
|
||||
if (def === "entry" && v.twin !== undefined) {
|
||||
const twin = t.entry.valid.find((x: any) => x.name === v.twin);
|
||||
assert.deepEqual(v.message, withheld(twin.message), `${v.name}: the twin of ${v.twin}`);
|
||||
assert.equal(v.message.record.answerer_count, twin.message.record.answerer_count, "a decider and a non-decider read the same count");
|
||||
bytesHaveNoId(v.messagePackHex, idsOf(twin.message), v.name);
|
||||
assert.ok(twin.messagePackHex.includes(Buffer.from(idsOf(twin.message)[0], "utf8").toString("hex").toUpperCase()), "the decider view does carry the ids");
|
||||
}
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const v of t[section].invalid) {
|
||||
|
|
@ -478,9 +519,10 @@ test("fork transport: requests and entries validate and encode to the vector byt
|
|||
}
|
||||
}
|
||||
for (const v of t.listInvalid) {
|
||||
const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0;
|
||||
const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0 || viewRuleErrors(v.message.forks).length > 0;
|
||||
assert.ok(refused, `${v.name}: ${v.why}`);
|
||||
if (v.refusal !== undefined) assert.deepEqual(orderErrors(v.message.forks.map((f: any) => f.card)), [v.refusal], v.name);
|
||||
if (v.name === "a_page_mixes_views") assert.equal(viewRuleErrors(v.message.forks).length, 1, v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
|
@ -489,12 +531,26 @@ test("fork transport: every list request replays to the vector's response, newes
|
|||
for (const sc of fork.transport.listScenarios) {
|
||||
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
|
||||
assert.deepEqual(orderErrors(sc.entries.map((e: any) => e.card)), [], sc.name);
|
||||
if (sc.twin !== undefined) {
|
||||
// the non-decider page is the decider page with the ids withheld: counts, order, cursor and stamp equal, byte for byte but the withheld members
|
||||
const twin = fork.transport.listScenarios.find((x: any) => x.name === sc.twin);
|
||||
assert.deepEqual(sc.entries, twin.entries.map(withheld), sc.name);
|
||||
assert.equal(sc.steps.length, twin.steps.length);
|
||||
sc.steps.forEach((step: any, i: number) => {
|
||||
const other = twin.steps[i].response;
|
||||
assert.deepEqual(step.response, { ...other, forks: other.forks.map(withheld) }, sc.name);
|
||||
assert.ok(twin.entries.length > 0 && step.response.forks.length > 0);
|
||||
bytesHaveNoId(step.responseHex, twin.entries.flatMap(idsOf), sc.name);
|
||||
});
|
||||
assert.ok(twin.steps[0].responseHex.includes(Buffer.from(OP_ID, "utf8").toString("hex").toUpperCase()), "the decider page does carry the ids");
|
||||
}
|
||||
let previous: { next_cursor: string | null } | null = null;
|
||||
for (const step of sc.steps) {
|
||||
const response = judgeList(sc.entries, sc.ordinal, step.request);
|
||||
assert.deepEqual(response, step.response, sc.name);
|
||||
assert.ok(check(forkSchema, "list", response), sc.name);
|
||||
assert.equal(hexOf(response), step.responseHex, sc.name);
|
||||
assert.deepEqual(viewRuleErrors(response.forks), [], sc.name);
|
||||
const orders = response.forks.map((f: any) => f.card.order);
|
||||
assert.deepEqual(orders, [...orders].sort((a: number, b: number) => b - a), sc.name);
|
||||
if (previous !== null) assert.equal(step.request.cursor, previous.next_cursor, `${sc.name}: the second request follows the first page's cursor`);
|
||||
|
|
|
|||
|
|
@ -173,9 +173,11 @@ def chash(c): return sha(canonical_json(c["title"], c["context"], c["options"],
|
|||
def card(**kw):
|
||||
c = {"schemaVersion": "gamecult.fork.card.v0", "fork_id": FID, "title": "Slot mark consent", "context": "Should a slot mark be shared?",
|
||||
"options": OPT, "recommendation": "no", "depends": DEP, "leans": None, "flag": None,
|
||||
"raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "raised_at": "2026-11-10T09:30:00Z", "deciders": [OP],
|
||||
"raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "raised_at": "2026-11-10T09:30:00Z", "deciders": [OP], "decider_count": 1,
|
||||
"status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""}
|
||||
c.update(kw); c["options_hash"] = chash(c); return c
|
||||
c.update(kw)
|
||||
if "decider_count" not in kw: c["decider_count"] = len(c["deciders"]) # the decider view: the count equals the ids; a withheld card passes its own
|
||||
c["options_hash"] = chash(c); return c
|
||||
CARD = card(); H = CARD["options_hash"]
|
||||
|
||||
hashes = []
|
||||
|
|
@ -202,7 +204,7 @@ hv("title_changed", card(title="Slot mark consent?"))
|
|||
hv("context_changed", card(context="Should slot marks be shared?"))
|
||||
hv("depends_order_changed", card(depends=DEP + ["x:question:y"]))
|
||||
hv("depends_order_changed_reversed", card(depends=["x:question:y"] + DEP))
|
||||
hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[OP, HM], status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, raised_at="2027-01-02T03:04:05.123Z", fork_id="other:question:id"))
|
||||
hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[], decider_count=2, status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, raised_at="2027-01-02T03:04:05.123Z", fork_id="other:question:id"))
|
||||
hv("control_characters_and_markup_are_escaped_minimally", card(title='q " b \\ t \t n \n bell \u0007 del \u007f', context="</script> \u2028 &"))
|
||||
hv("astral_option_id_and_text", card(options=[{"id": "\U0001F600", "text": "\U0001D11E"}, OPT[1]]))
|
||||
assert [h["sha256"] for h in hashes if h["heimdall"] == "D"] == [h["sha256"] for h in hashes if h["heimdall"] == "E"]
|
||||
|
|
@ -237,7 +239,10 @@ def da(outcome, link, code, b, server):
|
|||
"options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"],
|
||||
"claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]}
|
||||
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id
|
||||
def record(counted, answers, h=H, fork=FID): return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answers": answers}
|
||||
def record(counted, answers, h=H, fork=FID, answerer_count=None):
|
||||
# answerer_count: distinct answerers over every stored answer, refusals included; a withheld record passes the count it kept
|
||||
n = len({a["answerer"] for a in answers}) if answerer_count is None else answerer_count
|
||||
return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answerer_count": n, "answers": answers}
|
||||
T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z"
|
||||
def step(kind, body_=None, server=None, response=None, **kw):
|
||||
d = {"kind": kind, "body": body_, "server": server, "response": response}; d.update(kw); return d
|
||||
|
|
@ -385,15 +390,29 @@ def fcard(n, status="open", **kw):
|
|||
return c
|
||||
def fbody(c, choice, aid_note):
|
||||
return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"])
|
||||
def with_count(e, n): return dict(e, record=dict(e["record"], answerer_count=n))
|
||||
def withheld_answers(e):
|
||||
e = json.loads(json.dumps(e))
|
||||
for a in e["record"]["answers"]: a["answerer"] = None
|
||||
return e
|
||||
def fentry(c, answers=(), counted=None):
|
||||
return {"schemaVersion": "gamecult.fork.entry.v0", "card": c, "record": record(counted, list(answers), c["options_hash"], c["fork_id"])}
|
||||
def withheld(e):
|
||||
"""The non-decider twin of a decider-view entry: ids withheld, counts kept (fork-card.md, Reader views)."""
|
||||
e = json.loads(json.dumps(e)); e["card"]["deciders"] = []
|
||||
for a in e["record"]["answers"]: a["answerer"] = None
|
||||
return e
|
||||
q5 = fcard(5); q4 = fcard(4); q2 = fcard(2)
|
||||
q3 = fcard(3, "closed", ruling="cultlib-gaps:resolution:question.q3.n1", ruled_from=["n1"]); q1 = fcard(1, "closed", ruling="cultlib-gaps:resolution:question.q1.n1")
|
||||
a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2)))
|
||||
a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2)))
|
||||
r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward"))
|
||||
ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)]
|
||||
a5b = da("Revision", "n1", None, fbody(q5, "yes", NOTE), srv("n2", OP, T(4)))
|
||||
TWICE = fentry(q5, [a5, a5b], "n1") # two answers, one account
|
||||
q0 = fcard(0)
|
||||
WENTRIES = [withheld(e) for e in ENTRIES]
|
||||
MIXED = json.loads(json.dumps(WENTRIES[2])); MIXED["record"]["answers"][0]["answerer"] = OP # one answer named among nils
|
||||
ORDER0 = [fentry(fcard(3)), fentry(fcard(2)), fentry(fcard(1)), fentry(q0)] # a Mind whose oldest card has order 0
|
||||
q4b = dict(fcard(4), fork_id="gamecult-chat:question:q4b") # a second card on order 4 in the same Mind
|
||||
def order_shared(entries):
|
||||
|
|
@ -420,6 +439,7 @@ LISTS = [
|
|||
{"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]},
|
||||
{"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]},
|
||||
{"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]},
|
||||
{"name": "non_decider_page", "ordinal": 412, "entries": WENTRIES, "twin": "all_forks_one_page", "steps": [lstep(WENTRIES, 412, lreq(limit=50))]},
|
||||
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
|
||||
]
|
||||
# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first
|
||||
|
|
@ -550,6 +570,7 @@ fork = {
|
|||
{"name": "raised_at_nil", "message": card(raised_at=None), "messagePackHex": hx(card(raised_at=None))},
|
||||
{"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))},
|
||||
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))},
|
||||
{"name": "withheld_deciders", "message": card(deciders=[], decider_count=2), "messagePackHex": hx(card(deciders=[], decider_count=2))},
|
||||
{"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}],
|
||||
"invalid": [bad(n, w, m) for n, w, m in [
|
||||
("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)),
|
||||
|
|
@ -557,7 +578,13 @@ fork = {
|
|||
("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])),
|
||||
("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])),
|
||||
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
|
||||
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])),
|
||||
("decider_count_zero", "a fork with no decider cannot be answered; an empty deciders list now means withheld, so the count refuses it", card(deciders=[], decider_count=0)),
|
||||
("decider_count_missing", "map-mode writes every key: decider_count is required", {k: v for k, v in card().items() if k != "decider_count"}),
|
||||
("decider_count_65", "decider_count is at most 64 (Bifrost's per-call list)", card(deciders=[], decider_count=65)),
|
||||
("decider_count_string", "decider_count is an integer", card(deciders=[], decider_count="2")),
|
||||
("decider_count_null", "decider_count is never nil: it states the count even when the ids are withheld", card(deciders=[], decider_count=None)),
|
||||
("decider_count_fewer_than_the_ids", "rule R-card: a card that shows deciders shows all of them, so the count equals the ids (not expressible in the schema)", card(deciders=[OP, HM], decider_count=1)),
|
||||
("decider_count_more_than_the_ids_shown", "rule R-card: a visible list must be whole; a partial list is no view (not expressible in the schema)", card(deciders=[OP, HM], decider_count=3)),
|
||||
("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)),
|
||||
("unknown_key", "closed document", dict(card(), note="x")),
|
||||
("option_with_a_label", "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", card(options=[dict(OPT[0], label="Yes"), OPT[1]])),
|
||||
|
|
@ -566,7 +593,7 @@ fork = {
|
|||
("duplicate_option_ids", "rule: option ids are unique (not expressible in the schema)", card(options=[OPT[0], dict(OPT[1], id=OPT[0]["id"])])),
|
||||
("recommendation_not_an_option", "rule: recommendation is an option id (not expressible in the schema)", card(recommendation="maybe")),
|
||||
("leans_not_an_option", "rule: leans is nil or an option id (not expressible in the schema)", card(leans="maybe")),
|
||||
("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP])),
|
||||
("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP], decider_count=2)),
|
||||
("ruled_from_on_an_open_card", "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (not expressible in the schema)", card(ruled_from=["n1"])),
|
||||
("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")),
|
||||
("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])),
|
||||
|
|
@ -574,7 +601,7 @@ fork = {
|
|||
("raised_at_epoch_integer", "raised_at is an RFC3339 instant string or nil, not epoch milliseconds", card(raised_at=1762767000000)),
|
||||
("raised_at_date_only", "raised_at is an instant with a time and a Z, not a date", card(raised_at="2026-11-10")),
|
||||
("raised_at_missing", "map-mode writes every key: raised_at is required, nil when unknown", {k: v for k, v in card().items() if k != "raised_at"}),
|
||||
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"])),
|
||||
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"], decider_count=65)),
|
||||
]]},
|
||||
"request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")),
|
||||
|
|
@ -600,6 +627,7 @@ fork = {
|
|||
]]},
|
||||
"deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))),
|
||||
("withheld_answerer", dict(CNT, answerer=None)),
|
||||
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
|
||||
("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
|
|
@ -616,6 +644,8 @@ fork = {
|
|||
("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")),
|
||||
("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")),
|
||||
]]},
|
||||
"resultsInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("answer_result_with_a_nil_answerer", "rule R-answer-result: the answer is the sender's own, stamped with their account; a nil answerer exists only inside a record shown to a non-decider (not expressible in the schema)", dict(result(dict(CNT, answerer=None)), counted_answer_id="n1"))]],
|
||||
"results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS],
|
||||
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}],
|
||||
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
|
||||
|
|
@ -635,20 +665,34 @@ fork = {
|
|||
("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}),
|
||||
("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}),
|
||||
("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]},
|
||||
"entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]],
|
||||
"entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]]
|
||||
+ [dict({"name": n, "message": e, "messagePackHex": hx(e)}, twin=t) for n, e, t in [("withheld_open_counted", WENTRIES[0], "open_counted"), ("withheld_closed_with_ruling_and_a_refusal", WENTRIES[2], "closed_with_ruling_and_a_refusal"), ("withheld_no_answers", WENTRIES[1], "open_no_answers")]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}),
|
||||
("record_of_another_fork", "rule: the record names the card's fork (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], fork_id="gamecult-chat:question:q9"))),
|
||||
("record_under_another_hash", "rule: the record is under the card's options_hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], options_hash=STALE_H))),
|
||||
("counted_answer_not_derived", "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], counted_answer_id=None))),
|
||||
("answerer_count_missing", "map-mode writes every key: answerer_count is required", dict(ENTRIES[0], record={k: v for k, v in ENTRIES[0]["record"].items() if k != "answerer_count"})),
|
||||
("answerer_count_negative", "answerer_count is not negative", with_count(ENTRIES[0], -1)),
|
||||
("answerer_count_null", "answerer_count is never nil: a decider and a non-decider read the same number", with_count(ENTRIES[0], None)),
|
||||
("answerer_count_disagrees_with_the_ids", "rule R-record: in the decider view answerer_count is the number of distinct answerers (two here, not three; not expressible in the schema)", with_count(ENTRIES[2], 3)),
|
||||
("answerer_count_counts_answers_not_people", "rule R-record: two answers by one account are one answerer (not expressible in the schema)", with_count(TWICE, 2)),
|
||||
("withheld_card_with_a_named_answerer_among_nils", "rule R-entry: a withheld card beside one named answerer, even among nils (not expressible in the schema)", MIXED),
|
||||
("withheld_count_over_the_answers", "rule R-record: withheld answers are at most as many answerers as answers (two answers, count 3; not expressible in the schema)", with_count(WENTRIES[2], 3)),
|
||||
("withheld_count_zero_over_answers", "rule R-record: with at least one answer the count is at least 1 (not expressible in the schema)", with_count(WENTRIES[0], 0)),
|
||||
("withheld_count_over_no_answers", "rule R-record: with no answers the count is 0 in both views (not expressible in the schema)", with_count(WENTRIES[1], 1)),
|
||||
("decider_card_with_withheld_answers", "rule R-entry: a card that shows deciders is the decider view, so every answerer is named (not expressible in the schema)", withheld_answers(ENTRIES[0])),
|
||||
("withheld_card_with_named_answers", "rule R-entry, the leak: a card with deciders withheld beside an answer that still names its account (not expressible in the schema)", dict(ENTRIES[0], card=WENTRIES[0]["card"])),
|
||||
("card_hash_wrong", "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", dict(ENTRIES[1], card=dict(ENTRIES[1]["card"], options_hash=STALE_H), record=dict(ENTRIES[1]["record"], options_hash=STALE_H)))]]},
|
||||
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
|
||||
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
|
||||
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]]
|
||||
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1)),
|
||||
("a_page_mixes_views", "rule R-list: a page is one reader's view, so every entry agrees on whether the deciders are shown (not expressible in the schema)",
|
||||
dict(LISTS[1]["steps"][0]["response"], forks=[WENTRIES[0]] + LISTS[1]["steps"][0]["response"]["forks"][1:]))]]
|
||||
+ [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST),
|
||||
messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])],
|
||||
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
|
||||
"listScenarios": [dict({"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]}, **({"twin": l["twin"]} if "twin" in l else {})) for l in LISTS],
|
||||
"listShortPages": SHORT_PAGES,
|
||||
"readTooLarge": READ_TOO_LARGE,
|
||||
"answerTooLarge": ANSWER_TOO_LARGE,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ namespace GameCult.Networking.Tests
|
|||
if (c["leans"] is not null && !ids.Contains(S(c["leans"]))) yield return "leans is not an option";
|
||||
var deciders = c["deciders"]!.AsArray().Select(S).ToList();
|
||||
if (deciders.Distinct().Count() != deciders.Count) yield return "duplicate deciders";
|
||||
if (deciders.Count > 0 && deciders.Count != c["decider_count"]!.GetValue<int>()) yield return "R-card: shown deciders are not exactly decider_count ids";
|
||||
if (c["depends"]!.AsArray().Any(d => Encoding.UTF8.GetByteCount(S(d)) > 1000)) yield return "a depends line is over 1000 UTF-8 bytes";
|
||||
if (S(c["status"]) == "open" && c["ruling"] is not null) yield return "an open card names a ruling";
|
||||
if (c["ruling"] is null && c["ruled_from"]!.AsArray().Count > 0) yield return "ruled_from without a ruling";
|
||||
|
|
@ -258,10 +259,17 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(HashOf(copy), Is.Not.EqualTo(baseline), $"mutation {i}");
|
||||
}
|
||||
var hidden = Clone(card);
|
||||
hidden["fork_id"] = "other:question:id"; hidden["leans"] = "yes"; hidden["status"] = "closed"; hidden["ruled_from"] = new JsonArray("n1"); hidden["raised_at"] = "2027-01-02T03:04:05.123Z";
|
||||
hidden["fork_id"] = "other:question:id"; hidden["leans"] = "yes"; hidden["deciders"] = new JsonArray(); hidden["decider_count"] = 7; hidden["status"] = "closed"; hidden["ruled_from"] = new JsonArray("n1"); hidden["raised_at"] = "2027-01-02T03:04:05.123Z";
|
||||
Assert.That(HashOf(hidden), Is.EqualTo(baseline), "fork_id and the unshown members stay outside the hash");
|
||||
hidden["raised_at"] = null;
|
||||
Assert.That(HashOf(hidden), Is.EqualTo(baseline), "a nil raised_at stays outside the hash");
|
||||
foreach (var (ids, count) in new[] { (0, 2), (1, 64) })
|
||||
{
|
||||
var counted = Clone(card)!;
|
||||
counted["deciders"] = new JsonArray(card["deciders"]!.AsArray().Take(ids).Select(d => JsonValue.Create(S(d))).ToArray());
|
||||
counted["decider_count"] = count;
|
||||
Assert.That(HashOf(counted), Is.EqualTo(baseline), "decider_count and the emptiness of deciders stay outside the hash");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -291,6 +299,12 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(Errors(ForkSchema, "answerResult", v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
Assert.That(v["message"]!["card"] is not null, Is.EqualTo(S(v["name"]) == "stale_fork"), "only a stale refusal shows the new fork");
|
||||
Assert.That(ResultRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
|
||||
}
|
||||
foreach (var v in Fork["resultsInvalid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "answerResult", v!["message"]).Count > 0 || ResultRuleErrors(v["message"]!).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
Assert.That(Fork["doorRefusals"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("refusal")));
|
||||
foreach (var v in Fork["unavailable"]!.AsArray())
|
||||
|
|
@ -321,6 +335,12 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
}
|
||||
|
||||
// R-answer-result: the answer in an answer_result is the sender's own, stamped with their account, never nil (nil exists only in a record shown to a non-decider).
|
||||
private static IEnumerable<string> ResultRuleErrors(JsonNode r)
|
||||
{
|
||||
if (r["answer"]!["answerer"] is null) yield return "R-answer-result: the answerer is nil";
|
||||
}
|
||||
|
||||
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
|
||||
private static IEnumerable<string> NoteRuleErrors(string def, JsonNode? message)
|
||||
{
|
||||
|
|
@ -428,7 +448,8 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
var record = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "gamecult.fork.record.v0", ["fork_id"] = Clone(card["fork_id"]), ["options_hash"] = HashOf(card), ["counted_answer_id"] = Counted(), ["answers"] = answers
|
||||
["schemaVersion"] = "gamecult.fork.record.v0", ["fork_id"] = Clone(card["fork_id"]), ["options_hash"] = HashOf(card), ["counted_answer_id"] = Counted(),
|
||||
["answerer_count"] = answers.Select(a => S(a!["answerer"])).Distinct().Count(), ["answers"] = answers
|
||||
};
|
||||
return (responses, record);
|
||||
}
|
||||
|
|
@ -476,6 +497,7 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(JsonNode.DeepEquals(record, sc["record"]), Is.True, $"{name}: {record.ToJsonString()}");
|
||||
Assert.That(Errors(ForkSchema, "record", record), Is.Empty, name);
|
||||
Assert.That(HexOf(record), Is.EqualTo(S(sc["recordHex"])), name);
|
||||
foreach (var st in steps) if (st!["response"]?["answer"] is not null) Assert.That(ResultRuleErrors(st["response"]!), Is.Empty, name);
|
||||
foreach (var step in steps)
|
||||
{
|
||||
var r = step!["response"];
|
||||
|
|
@ -533,6 +555,38 @@ namespace GameCult.Networking.Tests
|
|||
var counted = answers.Where(a => (S(a!["outcome"]) is "Counted" or "Revision") && S(a["options_hash"]) == S(card["options_hash"])
|
||||
&& !answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))).Select(a => S(a!["answer_id"])).FirstOrDefault();
|
||||
if ((record["counted_answer_id"] is null ? null : S(record["counted_answer_id"])) != counted) yield return "counted_answer_id is not derived";
|
||||
// R-record: named answerers are counted by distinct id, nil ones by a bound
|
||||
var named = answers.Count(a => a!["answerer"] is not null); var total = answers.Count; var n = record["answerer_count"]!.GetValue<long>();
|
||||
if (total == 0 && n != 0) yield return "R-record: no answers and a count other than 0";
|
||||
else if (named == total && total > 0 && n != answers.Select(a => S(a!["answerer"])).Distinct().Count()) yield return "R-record: answerer_count is not the distinct answerers";
|
||||
else if (named == 0 && total > 0 && (n < 1 || n > total)) yield return "R-record: a withheld count outside 1..answers";
|
||||
// R-entry: the card's view and the record's view agree, so a withheld card never rides beside a named account
|
||||
var shown = card["deciders"]!.AsArray().Count > 0;
|
||||
if (shown && named < total) yield return "R-entry: a decider-view card with withheld answerers";
|
||||
if (!shown && named > 0) yield return "R-entry: a withheld card beside a named answerer";
|
||||
}
|
||||
|
||||
// R-list: all entries of one page are one reader's view.
|
||||
private static IEnumerable<string> ViewRuleErrors(JsonArray forks)
|
||||
{
|
||||
if (forks.Select(f => f!["card"]!["deciders"]!.AsArray().Count > 0).Distinct().Count() > 1) yield return "R-list: a page mixes views";
|
||||
}
|
||||
|
||||
// The non-decider twin of a decider-view entry: ids withheld, counts kept.
|
||||
private static JsonNode Withheld(JsonNode e)
|
||||
{
|
||||
var w = Clone(e)!;
|
||||
w["card"]!["deciders"] = new JsonArray();
|
||||
foreach (var a in w["record"]!["answers"]!.AsArray()) a!["answerer"] = null;
|
||||
return w;
|
||||
}
|
||||
private static List<string> IdsOf(JsonNode e) =>
|
||||
e["card"]!["deciders"]!.AsArray().Select(S).Concat(e["record"]!["answers"]!.AsArray().Select(a => S(a!["answerer"]))).ToList();
|
||||
private static void NoIdInBytes(string hex, IEnumerable<string> ids, string name)
|
||||
{
|
||||
var list = ids.ToList();
|
||||
Assert.That(list, Is.Not.Empty, name);
|
||||
foreach (var id in list) Assert.That(hex, Does.Not.Contain(Convert.ToHexString(Encoding.UTF8.GetBytes(id))), $"{name}: the withheld bytes carry {id}");
|
||||
}
|
||||
|
||||
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
|
||||
|
|
@ -643,6 +697,14 @@ namespace GameCult.Networking.Tests
|
|||
{
|
||||
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
|
||||
if (def == "entry") Assert.That(EntryRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
|
||||
if (def == "entry" && v["twin"] is not null)
|
||||
{
|
||||
var twin = t[section]!["valid"]!.AsArray().Single(x => S(x!["name"]) == S(v["twin"]));
|
||||
Assert.That(JsonNode.DeepEquals(v["message"], Withheld(twin!["message"]!)), Is.True, $"{S(v["name"])}: the twin of {S(v["twin"])}");
|
||||
Assert.That(v["message"]!["record"]!["answerer_count"]!.GetValue<long>(), Is.EqualTo(twin["message"]!["record"]!["answerer_count"]!.GetValue<long>()), "a decider and a non-decider read the same count");
|
||||
NoIdInBytes(S(v["messagePackHex"]), IdsOf(twin["message"]!), S(v["name"]));
|
||||
Assert.That(S(twin["messagePackHex"]), Does.Contain(Convert.ToHexString(Encoding.UTF8.GetBytes(IdsOf(twin["message"]!)[0]))), "the decider view does carry the ids");
|
||||
}
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
foreach (var v in t[section]!["invalid"]!.AsArray())
|
||||
|
|
@ -654,7 +716,9 @@ namespace GameCult.Networking.Tests
|
|||
foreach (var v in t["listInvalid"]!.AsArray())
|
||||
{
|
||||
var shared = OrderErrors(v!["message"]!["forks"]!.AsArray().Select(f => f!["card"]));
|
||||
Assert.That(Errors(ForkSchema, "list", v["message"]).Count > 0 || shared.Count > 0, Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
var views = ViewRuleErrors(v["message"]!["forks"]!.AsArray()).ToList();
|
||||
Assert.That(Errors(ForkSchema, "list", v["message"]).Count > 0 || shared.Count > 0 || views.Count > 0, Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
if (S(v["name"]) == "a_page_mixes_views") Assert.That(views, Has.Count.EqualTo(1));
|
||||
if (v["refusal"] is not null) Assert.That(shared, Is.EqualTo(new[] { S(v["refusal"]) }), S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
|
|
@ -669,12 +733,29 @@ namespace GameCult.Networking.Tests
|
|||
var entries = sc["entries"]!.AsArray();
|
||||
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
|
||||
Assert.That(OrderErrors(entries.Select(e => e!["card"])), Is.Empty, name);
|
||||
if (sc["twin"] is not null)
|
||||
{
|
||||
// the non-decider page is the decider page with the ids withheld: counts, order, cursor and stamp equal, byte for byte but the withheld members
|
||||
var twin = Fork["transport"]!["listScenarios"]!.AsArray().Single(x => S(x!["name"]) == S(sc["twin"]))!;
|
||||
Assert.That(JsonNode.DeepEquals(sc["entries"], new JsonArray(twin["entries"]!.AsArray().Select(e => Withheld(e!)).ToArray())), Is.True, name);
|
||||
var mine = sc["steps"]!.AsArray(); var theirs = twin["steps"]!.AsArray();
|
||||
Assert.That(mine.Count, Is.EqualTo(theirs.Count), name);
|
||||
for (var i = 0; i < mine.Count; i++)
|
||||
{
|
||||
var expected = Clone(theirs[i]!["response"])!;
|
||||
expected["forks"] = new JsonArray(expected["forks"]!.AsArray().Select(e => Withheld(e!)).ToArray());
|
||||
Assert.That(JsonNode.DeepEquals(mine[i]!["response"], expected), Is.True, name);
|
||||
NoIdInBytes(S(mine[i]!["responseHex"]), twin["entries"]!.AsArray().SelectMany(e => IdsOf(e!)), name);
|
||||
}
|
||||
Assert.That(S(theirs[0]!["responseHex"]), Does.Contain(Convert.ToHexString(Encoding.UTF8.GetBytes(IdsOf(twin["entries"]![0]!)[0]))), "the decider page does carry the ids");
|
||||
}
|
||||
JsonNode? previous = null;
|
||||
foreach (var step in sc["steps"]!.AsArray())
|
||||
{
|
||||
var response = JudgeList(entries, sc["ordinal"]!.GetValue<long>(), step!["request"]!);
|
||||
Assert.That(JsonNode.DeepEquals(response, step["response"]), Is.True, $"{name}: {response.ToJsonString()}");
|
||||
Assert.That(Errors(ForkSchema, "list", response), Is.Empty, name);
|
||||
Assert.That(ViewRuleErrors(response["forks"]!.AsArray()), Is.Empty, name);
|
||||
Assert.That(HexOf(response), Is.EqualTo(S(step["responseHex"])), name);
|
||||
var orders = response["forks"]!.AsArray().Select(f => f!["card"]!["order"]!.GetValue<long>()).ToList();
|
||||
Assert.That(orders, Is.Ordered.Descending, name);
|
||||
|
|
|
|||
Loading…
Reference in a new issue