CultNet governance contracts: sealed-vote API and fork-desk card (schemas, specs, vectors, C#/TS suites) #72
10 changed files with 19240 additions and 2 deletions
14658
contracts/cultnet/governance/fork-card-vectors.json
Normal file
14658
contracts/cultnet/governance/fork-card-vectors.json
Normal file
File diff suppressed because one or more lines are too long
193
contracts/cultnet/governance/fork-card.md
Normal file
193
contracts/cultnet/governance/fork-card.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# CultNet fork-desk contract v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost receiver), Huginn (the second door) and Void (Ratatoskr desk). No runtime implements it yet.
|
||||
Rule source: `/opt/commons/handoff/fork-desk-answer-binding-20261110.md` (Heimdall; sections 1.1 to 1.5, 1.3a to 1.3d), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Where this page differs from that file it says so under "Differences from the binding file".
|
||||
Format source: the fork desk artifact in `/opt/commons/handoff/fork-desk-original/` (ruling `eureka-substrate:ruling:fork-desk-nibu-format`).
|
||||
Files here: `fork-card.schema.json` (one schema, a `$defs` entry per document), `fork-card-vectors.json`. Conformance: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript), both generated against vectors written by `scripts/gen-governance-vectors.py`. Each suite computes every hash with its own code and replays every scenario through a reference judge written from this page.
|
||||
|
||||
All documents are CultNet map-mode: every key written, nil where absent, closed schemas. Field names are snake_case as in the binding file and the mind's records; `schemaVersion` keeps CultNet's spelling (open choice 1). The bytes of every document are pinned in the vectors as `messagePackHex`.
|
||||
|
||||
## Documents
|
||||
|
||||
| schema id | what | who writes it |
|
||||
|---|---|---|
|
||||
| `gamecult.fork.card.v0` | the fork as shown | the publisher (a Mind's card projection) |
|
||||
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
|
||||
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
|
||||
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
|
||||
| `gamecult.fork.refusal.v0` | a body refused at the door; nothing stored | Bifrost |
|
||||
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
|
||||
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
|
||||
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
|
||||
| `gamecult.fork.list.v0` | a page of entries, counts and a stamp | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.read_request.v0` | read one fork (Transport) | the desk |
|
||||
| `gamecult.fork.entry.v0` | one card with its record | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.unavailable.v0` | the outcome is not confirmed (the second door or Huginn did not answer); retry with the same `request_id` | Bifrost |
|
||||
|
||||
### card
|
||||
|
||||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], 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, 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`, `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; the Mind reads it and the card projection copies 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: option ids are unique; `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.
|
||||
|
||||
### Options hash (canonical bytes)
|
||||
|
||||
`options_hash` = lowercase hex SHA-256 of the UTF-8 bytes of the following, byte for byte:
|
||||
|
||||
1. Build an object with exactly the five members `title`, `context`, `options`, `recommendation`, `depends`. Each option is an object with exactly `id` and `text`.
|
||||
2. **NFC-normalise every string, object keys included.** No trimming, no newline rewriting, no case folding. A string with a lone surrogate is not a card (it has no UTF-8). Normalisation stability makes NFC stable across Unicode versions for already-assigned code points; a runtime's own normaliser is trusted and the vectors include a decomposed pair and astral code points (open choice 2).
|
||||
3. Serialise as RFC 8785 (JCS): no whitespace; object keys sorted by UTF-16 code unit, which for these ASCII keys is `context, depends, options, recommendation, title` and, inside an option, `id, text`; array order is as shown (option order and `depends` order are part of the hash).
|
||||
4. Strings are written in double quotes; only `"` and `\` are backslash-escaped, U+0008, U+0009, U+000A, U+000C, U+000D as `\b \t \n \f \r`, every other code point below U+0020 as `\u00xx` in lowercase hex; everything else is written raw as UTF-8 (U+007F, U+2028, `<`, `&` and astral code points included).
|
||||
5. **Numbers and nulls:** the member set contains none. There is no number to canonicalise (a number anywhere in the five members is a defect) and `recommendation` is never nil, so no nil is hashed.
|
||||
|
||||
`fork_id` is not in the hash and is bound beside it in its own field, so one hash cannot be replayed under another fork. The publisher computes the hash and stores it in the card; the client recomputes it from what it rendered and refuses to send on a mismatch; the receiver recomputes from its stored fork, never from the client.
|
||||
|
||||
Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed by the Python generator, by the C# suite and by the TypeScript suite independently, plus text, title, context and `depends`-order mutations, a case showing the unshown members do not move the hash, escapes and markup, and astral ids. Each suite also mutates every shown member, an option id included, and asserts the hash changes.
|
||||
|
||||
### answer_request
|
||||
|
||||
`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. A body refused at step 1 or 2 binds nothing, so its id is free to reuse. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`).
|
||||
|
||||
### desk_answer (the stored kind)
|
||||
|
||||
`{schemaVersion, answer_id, fork_id, outcome, of, retracts, code, options_hash, choice, note, answered_at, received_at, claim_in_future, answerer, authority, request_id}`, key `<campaign>:desk_answer:<question-label>.n<seq>`; `answer_id` is `n<seq>`, assigned by Huginn per question, and **an answer is named by its answer id** everywhere (`retracts`, `of`, `ruled_from`).
|
||||
|
||||
The stamped `authority` field of a stored answer is an **attribution**: the role the verified account holds (`Operator`, `Steward`, `Member`), copied from Bifrost's state. A stored answer confers no authority. Authority exists only where a ruling cites a counted answer ("Operator authority" below); the field keeps the binding file's spelling (open choice 8). One tagged `outcome`, each variant carrying only its own link:
|
||||
|
||||
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
|
||||
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
|
||||
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
|
||||
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried.
|
||||
|
||||
`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.
|
||||
|
||||
### Counting rules (in order; the first failure refuses with that code)
|
||||
|
||||
| step | rule | refusal | stored |
|
||||
|---|---|---|---|
|
||||
| 1 | verified session or bearer | `unauthenticated` (401) | no |
|
||||
| 2 | closed body; no `answerer`/`authority` at any depth; type-correct; not (no `answer` and not a retraction) | `body_forbidden_field`, `body_unknown_field`, `body_invalid` (400) | no |
|
||||
| 3 | `fork_id` is a stored fork | `fork_unknown` (404) | no |
|
||||
| 4 | `options_hash` equals the stored fork's hash | `stale_fork` (409) | **yes**; the result carries the current card |
|
||||
| 5 | the stamped `answerer` is on `deciders` | `not_a_decider` (403) | yes |
|
||||
| 6 | the fork is open (`status: open`) | `fork_closed` (409) | yes |
|
||||
| 7 | `choice`, if present, is an option id | `choice_invalid` (400) | yes |
|
||||
| 8 | first passing answer for `(fork_id, options_hash)` is `Counted`; later passing answers are `Revision`s naming the counted one | | yes |
|
||||
| 9 | a Matrix edit, redaction or reaction changes nothing | | no event is an answer |
|
||||
|
||||
**Retraction** (`retracted: true`, `retracts: <answer id>`, `answer: nil`, `options_hash` still carried, so step 4 applies) passes steps 1 to 6, then:
|
||||
|
||||
- R1 form: it carries no `answer`, it names a `Counted` or `Revision` answer of this fork under this `options_hash`; else `bad_retraction` (400). A retraction naming an answer already retracted is `already_retracted` (409). `retracts` on a body that is not a retraction is `bad_retraction`.
|
||||
- R2 who: the original answerer or any other decider; step 5 already requires a decider.
|
||||
- R3 is dropped and its label stays vacant, so R4 keeps its name in the binding file. A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a ruling cannot cite an answer on an open fork (that is the stale-click gap, and a card with `ruled_from` set and `status: open` is invalid), and a retraction after a ruling meets step 6: refused `fork_closed`, stored. There is no `ruled` code (vector `V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed`).
|
||||
- R4 counted answer: retracting the counted answer removes it from the count and the next passing answer in admission order counts again. Retracting an answer that was not counted changes nothing. After every answer is retracted, a fresh answer is `Counted`. Records stay append-only.
|
||||
|
||||
**The counted answer is derived**, not stored: the first `Counted` or `Revision` answer under the card's current `options_hash` that no `Retraction` names. When the card changes, answers under the old hash stop counting and the next answer under the new hash is `Counted` (vectors `a_changed_card_...`). The `record` projection carries it as `counted_answer_id`.
|
||||
|
||||
### Operator authority
|
||||
|
||||
A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_id`) a stored `desk_answer` whose outcome is `Counted` and whose stamped attribution (the answer's `authority` field) is `Operator` (or a stamped relay of her words from a mapped door, which this contract does not model). The citation is what gives a ruling authority: an attribution on an answer is a fact about the account, and authority exists only where a ruling cites a counted answer. The cited answer id is what `ruled_from` lists on the closed card. Negative vectors (`rulingCites`): no citation, a `Revision`, a `Refused` answer, a Counted answer by a non-operator, an answer that does not exist. Other authorities need no citation.
|
||||
|
||||
## Transport
|
||||
|
||||
Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors.
|
||||
|
||||
**Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3).
|
||||
|
||||
| call | route (proposal) | request | response | token |
|
||||
|---|---|---|---|---|
|
||||
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
|
||||
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` |
|
||||
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for a stored outcome of any kind, `Refused` included; the desk reads `outcome` and `code`, not the status), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
|
||||
|
||||
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
|
||||
|
||||
- **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; which forks a caller may see or answer is the card's audience and decider list.
|
||||
- **M5. The decider list reaches Huginn as a typed request on the same peer-checked door, sent only by Bifrost, from the admin-set list.** It is never taken from a Mind and never from a request body: 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 document and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it.
|
||||
|
||||
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).
|
||||
|
||||
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals.
|
||||
|
||||
**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`** (the admission ordinal). `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. 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). Which cards and answers a viewer may see follows the card's audience (PR #24 section 5); a fork the viewer may not see is `fork_unknown`, not a different refusal.
|
||||
|
||||
**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. **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.
|
||||
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
|
||||
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider-list request, sent from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the decider-list request it receives (M5) and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
|
||||
|
||||
## Old format, the mind's record, and what maps to what
|
||||
|
||||
The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a shared store. The mind's `question` record is the source in the new design; the card is its projection.
|
||||
|
||||
| old desk | mind `question` record | card |
|
||||
|---|---|---|
|
||||
| `qid`, `id` | key `campaign:question:label` | `fork_id` |
|
||||
| `title` | `title` | `title` |
|
||||
| `body` | `question` (the long text) | `context` |
|
||||
| `depends`: a free string | `depends`: up to 8 prose lines (`huginn schema question`) | `depends`: the lines verbatim |
|
||||
| `options[{key, label, text}]` | `options[{label, text}]` | `options[{id, text}]` |
|
||||
| option `key` | option `label` (a slug, the id) | option `id` |
|
||||
| option `label` (a human title) | none | **dropped**: not stored, not hashed; shown text is `text` |
|
||||
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
|
||||
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
|
||||
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
|
||||
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
|
||||
| `order` | none (admission ordinal) | `order` |
|
||||
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
|
||||
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
|
||||
| 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` |
|
||||
| "Changing an answer is fine until it's admitted as a ruling" | | a later answer is a `Revision`; `Retraction` withdraws one; neither changes who counted first, and a ruling that answers the fork closes it, after which no answer or retraction is taken (`fork_closed`) |
|
||||
|
||||
Differences that need Void's choice: the old page **overwrites** one answer per question and the new store **appends**, so the booth cannot show "your answer" as one mutable field (it shows the latest answer by the person, plus its history); the old page needs no identity and the new one stamps `answerer` from a verified session; the old `label` has nowhere to live unless the card's `text` carries it.
|
||||
|
||||
## Transport note for Void: Matrix event versus how the mind carries questions
|
||||
|
||||
How a mind carries a question today: a typed `question` record in Huginn (`huginn view`, `huginn query`); the Persona relays it to the operator in chat, and the operator's reply comes back as chat text that Self reads and admits as a `ruling` in her words. Identity is whoever the chat attributes the message to; nothing binds an answer to the question text she saw.
|
||||
|
||||
Option A, **a Matrix event in the Mind's room with the answer as a reply.** Gains: it sits where people already are, needs no sign-in beyond Matrix, and the thread shows the exchange. Loses: the sender is an mxid, not a Bifrost account, so `answerer` and `authority` have no verified source; an edit or redaction changes the visible message but not the decision (binding rule 9), so the thread and the record can disagree; there is nowhere to carry `options_hash`, so a stale card cannot be refused; and a reply is free text, so `choice` is lost.
|
||||
|
||||
Option B, **Bifrost receives the answer and Huginn stores it** (the design in this page; Ratatoskr PR #24 also withdraws the Matrix carrier). Gains: a verified account, a bound hash, refusals that are stored and visible, one counted answer, retractions. Loses: the booth needs Bifrost sign-in, which does not reach chat yet (Bifrost PR #9 and #37 design it), so until then the desk is read-only; and the exchange is no longer in the room.
|
||||
|
||||
**Recommendation: B, with Matrix as presentation only.** The Mind may post a card as a listing and a link into the desk; no Matrix event is ever an answer (rule 9). Confidence: high on the rule (it is Heimdall's and Eureka's already), moderate on the interim, where read-only until sign-in is a real cost; if the operator needs to answer before sign-in reaches chat, the honest stopgap is the old path (chat text, Self admits a ruling in her words) rather than a Matrix reply the record cannot bind.
|
||||
|
||||
## Differences from the binding file
|
||||
|
||||
- Spelling: every field is as the binding file names it (`fork_id`, `options_hash`, `answered_at`, `retracted`, `retracts`, `answer.choice`, `answer.note`, `recommendation`, `depends`, option `id`), except that `schemaVersion` keeps CultNet's camelCase and `answer_id` replaces the earlier `receipt id` (Persona brief).
|
||||
- Map-mode writes every key: `retracted` is always present; nothing is "optional".
|
||||
- `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4).
|
||||
- The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`.
|
||||
- `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too.
|
||||
- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`.
|
||||
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
1. Case of field names: snake_case for the fork documents (as the binding file) beside camelCase for the vote documents (as Bifrost's serializer). One convention would be tidier.
|
||||
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
|
||||
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
|
||||
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
|
||||
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale.
|
||||
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
|
||||
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the ordinal alone decides order.
|
||||
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
|
||||
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
|
||||
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
|
||||
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.
|
||||
965
contracts/cultnet/governance/fork-card.schema.json
Normal file
965
contracts/cultnet/governance/fork-card.schema.json
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/fork-card.schema.json",
|
||||
"title": "CultNet fork-desk contract v0",
|
||||
"description": "A fork card, the answer request a decider sends, the stored desk_answer with its tagged outcome, the result and door refusals returned to the sender, the record a fork keeps, and the ruling citation. Map-mode: every key is written, nil where absent. A request never carries answerer or authority: Huginn stamps both. Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md. Spec: fork-card.md. Vectors: fork-card-vectors.json.",
|
||||
"$defs": {
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answerId": {
|
||||
"type": "string",
|
||||
"pattern": "^n[1-9][0-9]*$"
|
||||
},
|
||||
"option": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"title",
|
||||
"context",
|
||||
"options",
|
||||
"recommendation",
|
||||
"depends",
|
||||
"leans",
|
||||
"flag",
|
||||
"raised_in",
|
||||
"deciders",
|
||||
"status",
|
||||
"ruling",
|
||||
"ruled_from",
|
||||
"order",
|
||||
"options_hash"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.card.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/option"
|
||||
}
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"depends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"leans": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"flag": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"raised_in": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"open",
|
||||
"closed"
|
||||
]
|
||||
},
|
||||
"ruling": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ruled_from": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"words": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
]+$)[\\s\\S]+$",
|
||||
"description": "At most 4000 UTF-8 bytes, which a schema cannot say: maxLength here counts code points and is only the looser bound (a code point is at least one byte). The byte limit is a rule both suites enforce (fork-card.md, answer_request)."
|
||||
},
|
||||
"answerObject": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"answer",
|
||||
"retracted",
|
||||
"retracts",
|
||||
"answered_at",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_request.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"answer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerObject"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"retracted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerCounted": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Counted"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRevision": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Revision"
|
||||
},
|
||||
"of": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRetraction": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Retraction"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRefused": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Refused"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"stale_fork",
|
||||
"not_a_decider",
|
||||
"fork_closed",
|
||||
"choice_invalid",
|
||||
"bad_retraction",
|
||||
"already_retracted"
|
||||
]
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerCounted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRevision"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRetraction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRefused"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer",
|
||||
"card"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_result.v0"
|
||||
},
|
||||
"answer": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
},
|
||||
"card": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"body_forbidden_field",
|
||||
"body_unknown_field",
|
||||
"body_invalid",
|
||||
"fork_unknown",
|
||||
"insufficient_scope",
|
||||
"forbidden",
|
||||
"request_invalid",
|
||||
"request_id_conflict"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"counted_answer_id",
|
||||
"answers"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.record.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rulingCite": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"ruling_id",
|
||||
"authority",
|
||||
"answer_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.ruling_cite.v0"
|
||||
},
|
||||
"ruling_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"pattern": "^c[1-9][0-9]*$",
|
||||
"description": "Opaque to a client. c<N> means: the cards whose order is below N."
|
||||
},
|
||||
"stamp": {
|
||||
"type": "string",
|
||||
"pattern": "^s[0-9]+$",
|
||||
"description": "s<N>, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents. Two lists with the same stamp are the same state."
|
||||
},
|
||||
"listRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"status",
|
||||
"cursor",
|
||||
"limit"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"status": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"open",
|
||||
"recorded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
"readRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"fork_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.read_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"card",
|
||||
"record"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.entry.v0"
|
||||
},
|
||||
"card": {
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
"record": {
|
||||
"$ref": "#/$defs/record"
|
||||
}
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"stamp",
|
||||
"open_count",
|
||||
"recorded_count",
|
||||
"forks",
|
||||
"next_cursor"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"stamp": {
|
||||
"$ref": "#/$defs/stamp"
|
||||
},
|
||||
"open_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"recorded_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"forks": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailable": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.unavailable.v0"
|
||||
},
|
||||
"code": {
|
||||
"const": "unavailable"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "The outcome is NOT CONFIRMED: the request may have reached Huginn and been stored before the reply was lost. Retry with the same request_id to learn the outcome."
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
"description": "Set by the client, a lowercase D-format UUID. Bound to the stamped answerer by the first stored answer that carries it."
|
||||
}
|
||||
}
|
||||
}
|
||||
1231
contracts/cultnet/governance/vote-api-vectors.json
Normal file
1231
contracts/cultnet/governance/vote-api-vectors.json
Normal file
File diff suppressed because it is too large
Load diff
107
contracts/cultnet/governance/vote-api.md
Normal file
107
contracts/cultnet/governance/vote-api.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# CultNet vote API v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost) and a Ratatoskr voting module. No server implements it yet.
|
||||
Files here: `vote-api.schema.json` (one schema, a `$defs` entry per document), `vote-api-vectors.json`. Conformance suites: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript). The vectors are written by `scripts/gen-governance-vectors.py`.
|
||||
Facts it rests on: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Bifrost `416f11a`; `416f11a..276f611` changes four files: a Standard-close concurrency fix in `MotionGovernanceService.cs`, two test files and `docs/deploy-main-to-live.md`; none touches the sealed API, so the facts hold at `276f611`) and Bifrost PR #37 (`docs/ratatoskr-voting-auth.md`, see "PR #37" below).
|
||||
|
||||
## Scope
|
||||
|
||||
**Which API covers which.** This contract covers **sealed-ballot motions only** (`gamecult.sealed_ballot.v0`). **Standard motions are not covered here.** Bifrost PR #37 also serves them (`PUT /api/votes/motions/{id}/ballot`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`), and that API is #37's own: this page defines no document for it, and a Standard motion id sent to any exchange below is answered `not_found`. Today Standard motions have no JSON read or cast API at all, no Mind path, and no receipt (open choice 1). The contract names four exchanges and one refusal document:
|
||||
|
||||
| exchange | request | response |
|
||||
|---|---|---|
|
||||
| motion list | `gamecult.vote.motion_list_request.v0` | `gamecult.vote.motion_list.v0` |
|
||||
| motion read | `gamecult.vote.motion_read_request.v0` | `gamecult.vote.motion.v0` |
|
||||
| cast (commit or reveal) | `gamecult.vote.cast.v0` | `gamecult.vote.receipt.v0` |
|
||||
| any refusal | | `gamecult.vote.refusal.v0` |
|
||||
|
||||
Every document is CultNet map-mode: all keys are written, nil where absent, and the schema is closed (`additionalProperties: false`). Instants are RFC 3339 UTC with a `Z` and up to seven fractional digits. Ids are lowercase `D`-format UUIDs. A motion states `highStakes`, a boolean fixed when the motion opens and never changed after (Bifrost PR #37, D2): it is the property that makes a commit demand a fresh sign-in (`reauthentication_required`). The bytes of every document are pinned by `messagePackHex` in the vectors (shortest integers, shortest strings, keys in the order the schema lists them); JSON with the same keys is the xenos-boundary form of the same documents (open choice 4: the wire format).
|
||||
|
||||
## Documents
|
||||
|
||||
- **motion_list_request**: `{schemaVersion, phase}`; `phase` is `Commit`, `Reveal`, `Closed` or nil for all.
|
||||
- **motion_list**: `{schemaVersion, motions: [{id, title, round, phase, commitClosesAtUtc, closesAtUtc, highStakes}]}`. **Heimdall must add this endpoint**: Bifrost has no list route for sealed motions today, only fetch-by-id. No pagination in v0 (open choice 2).
|
||||
- **motion_read_request**: `{schemaVersion, motionId}`.
|
||||
- **motion**: Bifrost's `SealedMotionState` with its names: `{schemaVersion, id, title, summary, round, priorMotionId, ballotSchema, phase, candidates (two or more), commitClosesAtUtc, closesAtUtc, highStakes, yourVoterId, yourCommitment (a receipt or nil), announcement}`. It never carries a tally, a turnout, or another voter's commitment or ballot (a `tally` key is a negative vector). `yourVoterId` is the voter's `UserAccount.Id`, the `voterUserAccountId` of the commitment.
|
||||
- **cast**: `{schemaVersion, motionId, verb, commitment, ballot}` with exactly one of two shapes: `verb: "commit"` with a 64-hex `commitment` and `ballot: nil`, or `verb: "reveal"` with `commitment: nil` and a `ballot`. One document for both verbs; today's two routes (`/commit`, `/reveal`) may stay, taking this document as the body, with a `verb` that must match the route (open choice 3).
|
||||
- **ballot** (inside a reveal): `{schema: "gamecult.sealed_ballot.v0", motionId, round, candidate, reason, salt, persona}`. `candidate` and `reason` are single-line and **already trimmed** (no Unicode White_Space at either end, using the White_Space list of `write-receipt.md`); the contract refuses an untrimmed value rather than trimming it, so the bytes hashed are the bytes sent. **This is a behaviour change Bifrost must make:** it trims `candidate` and `reason` silently on reveal today (facts file section 3c, `SealedBallotService.cs:154, 385-387`), so an untrimmed reveal is accepted and hashed trimmed; under this contract it is `invalid` (open choice 5). `reason` is 1 to 280 characters counted in UTF-16 code units, as Bifrost counts. A JSON Schema `maxLength` counts code points, so the schema alone accepts up to 280 astral characters; both suites add the rule (vectors `cast_reveal_reason_140_astral_280_units` valid, `ballot_reason_141_astral_282_units` refused). `salt` is 32 to 128 lowercase hex. `persona` is `""` for a member and a printable-ASCII, space-free id of at most 64 characters for a Mind.
|
||||
- **receipt**: Bifrost's `SealedBallotReceipt`: `{schemaVersion, motionId, voterUserAccountId, commitment, revision (>= 1), committedAtUtc, revealed}`. The receipt for a reveal has `revealed: true` and **echoes nothing of the ballot** (no candidate, reason or salt; negative vectors for each).
|
||||
- **refusal**: `{schemaVersion, code, error, retryAfterSeconds}`; the `code` set is closed:
|
||||
|
||||
| code | HTTP | trigger |
|
||||
|---|---|---|
|
||||
| `unauthenticated` | 401 | no bearer, or one Heimdall does not know (the API must answer this, not redirect to a sign-in page) |
|
||||
| `reauthentication_required` | 401 | a commit on a `highStakes` motion with no sign-in fresh enough (#37 D2: `prompt=login`, `max_age`); the voter signs in again at the top level and retries |
|
||||
| `forbidden` | 403 | authenticated but not an Active member, or a Mind key used on a route it may not call |
|
||||
| `not_found` | 404 | unknown motion id, a Standard motion id, or a reveal with no prior commit |
|
||||
| `wrong_phase` | 409 | commit outside Commit, reveal outside Reveal |
|
||||
| `contended` | 409 | a reveal raced the close fence: send it again |
|
||||
| `invalid` | 400 | the body fails the schema or an in-document rule (untrimmed text, candidate not in the motion, `round` or `motionId` mismatch, `persona` wrong for the credential) |
|
||||
| `commitment_mismatch` | 400 | a reveal whose canonical bytes do not hash to the stored commitment |
|
||||
| `payload_too_large` | 413 | body over 4096 bytes |
|
||||
| `rate_limited` | 429 | cast rate exceeded, per account; `retryAfterSeconds >= 1`, mirrored in `Retry-After` |
|
||||
| `held` | 503 | the Ratatoskr voting brake is set (#37 D5): nothing is read or stored while it is |
|
||||
|
||||
Each code has a vector (`refusals`), and every failure of the schema has a negative vector (`invalid`).
|
||||
|
||||
**Not on Bifrost's member API yet.** The **413 body cap of 4096 bytes** and the **429 per-account cast limit** are not on Bifrost's member sealed API today (facts file sections 3c and 5): both exist only on the Mind intake route (4096 bytes; 30 requests a minute per source address). #37 proposes 30 casts per 10 minutes per account for the member routes (its D6). `reauthentication_required` and `held` are #37 proposals (D2, D5) and exist nowhere in code. All four are spec text until Heimdall builds them.
|
||||
|
||||
**Two refusals Bifrost must split first.** Bifrost returns a reveal whose bytes do not hash to the stored commitment as a plain `400` `Invalid` ("The ballot does not match the stored commitment."), the same status and error type as every other bad body, so `commitment_mismatch` and `invalid` are one today. It returns a contended reveal (a race with the close fence, `SealedBallotService.cs:186-189`) as `WrongPhase` `409`, the same type as a reveal outside the Reveal phase, so `contended` and `wrong_phase` are one today. A client cannot tell "send it again" from "too late", or "wrong ballot" from "malformed body", until Bifrost splits each pair.
|
||||
|
||||
## Commitment
|
||||
|
||||
`commitment = lowercase hex SHA-256 of the UTF-8 bytes of`
|
||||
|
||||
gamecult.sealed_ballot.v0 \n {motionId} \n {voterUserAccountId} \n {persona} \n {round} \n {candidate} \n {reason} \n {salt}
|
||||
|
||||
with `\n` U+000A, ids in `D` format, `round` in decimal, `persona` empty for a member. This is Bifrost's formula as the code has it (`SealedBallotService.cs:154, 385-387`); `docs/sealed-ballot-motion.md:22-23` omits `{persona}` and is stale. The vectors give the canonical bytes (`canonicalHex`) and the digest for a member, a Mind, non-ASCII text, and a decomposed-versus-precomposed pair (no normalisation: they are different ballots). `pairs` pins what a reveal must match: the right ballot matches; a changed reason, another voter id, or a dropped persona does not, and the answer is `commitment_mismatch`.
|
||||
|
||||
## Receipts the voter can check later
|
||||
|
||||
A cast returns the receipt. The voter checks it later in two places that must agree byte for byte with it: `motion.yourCommitment` on a motion read (a commit receipt is present from the commit; the revision counts re-commits), and, after close, the `commitment` the result lists for them. The contract adds no receipt id or signature: the receipt is the commitment, its revision and the time (open choice 6).
|
||||
|
||||
## Personas
|
||||
|
||||
A Persona votes only on sealed motions, through a Mind key (Bifrost binds an Ed25519 public key 1:1 to an Active member account). The ballot's `persona` is the bound persona id and the commitment covers it, so a Mind's ballot cannot be replayed as a member's (`pairs`: dropping the persona does not match). The Mind-intake route today takes a signed `MindEnvelope` anonymously; this contract does not model that envelope, nonce or signature (open choice 7). Standard motions have no Mind path.
|
||||
|
||||
## Authentication
|
||||
|
||||
On the wire the credential is an opaque `Authorization: Bearer` header. The token route, claims, scopes and audience belong to Heimdall and Bifrost and are not designed here. A request without a usable bearer is answered `unauthenticated` as a typed refusal.
|
||||
|
||||
**Auth per route, now and after #37.** Routes are Bifrost's; this contract fixes the documents, not the paths (open choice 3).
|
||||
|
||||
| exchange | today (`/api/sealed-motions`, facts file 3c) | after #37 (`/api/votes`) |
|
||||
|---|---|---|
|
||||
| motion list | no route | bearer, scope `votes.read` |
|
||||
| motion read | session cookie + Active member | bearer, scope `votes.read` |
|
||||
| commit, reveal | session cookie + Active member | bearer, scope `votes.cast`, audience `bifrost-votes`; a high-stakes commit also needs a fresh sign-in |
|
||||
| Mind cast | anonymous; the Ed25519 signature on a `MindEnvelope` is the credential | unchanged: Minds never use `/api/votes` (#37) |
|
||||
| open a motion, Mind-key registry | session cookie + moderator | unchanged, cookie only |
|
||||
|
||||
Today a member route has **no bearer scheme**: it accepts the host-only `__Host-bifrost_session` cookie and answers an unauthenticated call with a redirect to the sign-in page, not a 401. After #37 `/api/votes` is bearer-only and answers a typed 401. The wire header stays an opaque bearer either way; only Bifrost knows which of the two it is checking.
|
||||
|
||||
## PR #37 (Bifrost `docs/ratatoskr-voting-auth.md`, head `447a7cb`)
|
||||
|
||||
Checked 2026-11-10 by an Eyes pass over the PR and the schema; conflicts for Heimdall:
|
||||
|
||||
1. **Standard motions.** The PR serves them (`PUT /api/votes/motions/{id}/ballot {choice, comment?}`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`); the contract is sealed-only and answers `not_found` for a Standard id.
|
||||
2. **Verb model.** The PR keeps `POST .../commit` and `.../reveal` and adds `GET .../result`; the contract has one `cast` document and no result document.
|
||||
3. **List and read as GET.** The PR names GET routes; the contract's request documents carry a `phase` filter and a `motionId`. The mapping (query string and path) is undefined until Heimdall fixes the routes.
|
||||
4. **Refusals.** The PR lists 401/403/404/409/429 with `WWW-Authenticate`; the contract adds 400 (`invalid`, `commitment_mismatch`) and 413, and a typed body.
|
||||
5. **Minds.** The PR says Minds never use `/api/votes` and keeps the anonymous signed `/mind` route; the contract carries `persona` in the ballot and a `forbidden` case for a Mind key on a route it may not call. The PR's token is never a Persona's.
|
||||
6. **Authority.** The contract's `unauthenticated` says a bearer "Heimdall does not know"; the PR makes Bifrost the OIDC issuer (a second client, scopes `votes.read` and `votes.cast`, audience `bifrost-votes`) and notes that Bifrost OIDC is not live (its D7).
|
||||
7. **Rate limits.** The PR keys per account (30 casts per 10 minutes); the contract now says per account too, and neither exists on the member API yet (above).
|
||||
8. **Wire format.** The PR's errors are JSON; the contract's vectors are MessagePack. Nothing settles the media type.
|
||||
9. **The PR's seven open decisions** (consent for `votes.cast`, re-authentication, no refresh token, grant revocation, a Ratatoskr brake, rate numbers, MAS rollout order) assume nothing the contract contradicts. D2 (re-authentication at cast) is answered yes for sealed commit in this contract: `reauthentication_required`, with `highStakes` fixed at open. D5 (a brake) is `held`.
|
||||
|
||||
## Open choices for Heimdall
|
||||
|
||||
1. Standard motions in v0 or a v1 (the PR already serves them).
|
||||
2. Pagination and filters on the list.
|
||||
3. One `cast` route or the two existing ones; `verb` redundancy.
|
||||
4. Wire format: MessagePack documents with a JSON export, or JSON only (the PR is JSON).
|
||||
5. Refuse untrimmed `candidate` and `reason` (this contract) or keep trimming server-side.
|
||||
6. Whether a receipt should carry an id or a server signature so a voter can prove it to a third party.
|
||||
7. The Mind path: keep the signed envelope on its own route, or take Mind keys as a bearer credential.
|
||||
8. `reason` length in UTF-16 code units (as Bifrost) or code points.
|
||||
9. Whether the result document (`GET .../result`) joins this contract.
|
||||
420
contracts/cultnet/governance/vote-api.schema.json
Normal file
420
contracts/cultnet/governance/vote-api.schema.json
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/vote-api.schema.json",
|
||||
"title": "CultNet vote API v0 (sealed-ballot motions)",
|
||||
"description": "Typed documents for listing, reading and casting on sealed-ballot motions, and the receipt a cast returns. Map-mode: every key is written, nil where absent. Spec: vote-api.md. Vectors: vote-api-vectors.json.",
|
||||
"$defs": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"trimmedLine": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"motionId",
|
||||
"round",
|
||||
"candidate",
|
||||
"reason",
|
||||
"salt",
|
||||
"persona"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"candidate": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"salt": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32,128}$"
|
||||
},
|
||||
"persona": {
|
||||
"type": "string",
|
||||
"pattern": "^[!-~]{0,64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"phase"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list_request.v0"
|
||||
},
|
||||
"phase": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"round",
|
||||
"phase",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionList": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motions"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list.v0"
|
||||
},
|
||||
"motions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/motionListItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionReadRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_read_request.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"voterUserAccountId",
|
||||
"commitment",
|
||||
"revision",
|
||||
"committedAtUtc",
|
||||
"revealed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.receipt.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"voterUserAccountId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"committedAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"revealed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"title",
|
||||
"summary",
|
||||
"round",
|
||||
"priorMotionId",
|
||||
"ballotSchema",
|
||||
"phase",
|
||||
"candidates",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes",
|
||||
"yourVoterId",
|
||||
"yourCommitment",
|
||||
"announcement"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion.v0"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"priorMotionId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ballotSchema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
}
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
},
|
||||
"yourVoterId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"yourCommitment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/receipt"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"announcement": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"commitCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "commit"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"revealCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "reveal"
|
||||
},
|
||||
"commitment": {
|
||||
"type": "null"
|
||||
},
|
||||
"ballot": {
|
||||
"$ref": "#/$defs/ballot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/commitCast"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/revealCast"
|
||||
}
|
||||
]
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"reauthentication_required",
|
||||
"not_found",
|
||||
"wrong_phase",
|
||||
"contended",
|
||||
"invalid",
|
||||
"commitment_mismatch",
|
||||
"payload_too_large",
|
||||
"rate_limited",
|
||||
"held"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
465
packages/cultnet-ts/test/governance-vectors.test.ts
Normal file
465
packages/cultnet-ts/test/governance-vectors.test.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
|
||||
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with this runtime and must reproduce the bytes, judges
|
||||
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
|
||||
const DIR = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "governance");
|
||||
const read = (name: string) => JSON.parse(readFileSync(join(DIR, name), "utf8"));
|
||||
const vote = read("vote-api-vectors.json");
|
||||
const fork = read("fork-card-vectors.json");
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
const voteSchema = read("vote-api.schema.json");
|
||||
const forkSchema = read("fork-card.schema.json");
|
||||
ajv.addSchema(voteSchema);
|
||||
ajv.addSchema(forkSchema);
|
||||
const check = (schema: { $id: string }, def: string, value: unknown): boolean => {
|
||||
const validate = ajv.getSchema(`${schema.$id}#/$defs/${def}`);
|
||||
assert.ok(validate, `no schema def ${def}`);
|
||||
return validate(value) as boolean;
|
||||
};
|
||||
const hexOf = (value: unknown): string => Buffer.from(encode(value)).toString("hex").toUpperCase();
|
||||
const sha = (bytes: Buffer | string): string => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
|
||||
const voteRuleErrors = (def: string, message: any): string[] =>
|
||||
def === "cast" && message.ballot !== null && message.ballot.reason.length > 280 ? ["reason is over 280 UTF-16 code units"] : [];
|
||||
|
||||
test("vote API: every valid document validates, encodes to the vector bytes and decodes back", () => {
|
||||
for (const v of vote.valid) {
|
||||
assert.ok(check(voteSchema, v.def, v.message), v.name);
|
||||
assert.deepEqual(voteRuleErrors(v.def, v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: every negative is refused by the schema, and its bytes are the vector's", () => {
|
||||
for (const v of vote.invalid) {
|
||||
assert.ok(!check(voteSchema, v.def, v.message) || voteRuleErrors(v.def, v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: the commitment is SHA-256 of the canonical bytes", () => {
|
||||
for (const c of vote.commitment) {
|
||||
const b = c.ballot;
|
||||
const bytes = Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${c.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8");
|
||||
assert.equal(bytes.toString("hex").toUpperCase(), c.canonicalHex, c.name);
|
||||
assert.equal(sha(bytes), c.sha256, c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: a reveal matches its commit exactly when the ballot hashes to the commitment", () => {
|
||||
for (const p of vote.pairs) {
|
||||
const b = p.reveal.ballot;
|
||||
const digest = sha(Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${p.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8"));
|
||||
assert.equal(digest === p.commit.commitment, p.matches, p.name);
|
||||
assert.ok(check(voteSchema, "cast", p.commit) && check(voteSchema, "cast", p.reveal), p.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: each refusal code has a vector, a status and the vector bytes", () => {
|
||||
const codes = (voteSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
assert.deepEqual(vote.refusals.map((r: { message: { code: string } }) => r.message.code).sort(), [...codes].sort());
|
||||
for (const r of vote.refusals) {
|
||||
assert.ok(check(voteSchema, "refusal", r.message), r.name);
|
||||
assert.equal(hexOf(r.message), r.messagePackHex, r.name);
|
||||
assert.ok(r.httpStatus >= 400, r.name);
|
||||
}
|
||||
assert.ok(vote.refusals.find((r: { name: string }) => r.name === "rate_limited").message.retryAfterSeconds >= 1);
|
||||
});
|
||||
|
||||
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
|
||||
const jstr = (s: string): string => {
|
||||
let out = '"';
|
||||
for (const ch of s) {
|
||||
const o = ch.codePointAt(0)!;
|
||||
out += ch === '"' ? '\\"' : ch === "\\" ? "\\\\" : o === 8 ? "\\b" : o === 9 ? "\\t" : o === 10 ? "\\n" : o === 12 ? "\\f" : o === 13 ? "\\r"
|
||||
: o < 0x20 ? "\\u" + o.toString(16).padStart(4, "0") : ch;
|
||||
}
|
||||
return out + '"';
|
||||
};
|
||||
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; deciders: string[]; 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 =>
|
||||
Buffer.from(`{"context":${jstr(nfc(c.context))},"depends":[${c.depends.map((d) => jstr(nfc(d))).join(",")}],"options":[${c.options.map((o) => `{"id":${jstr(nfc(o.id))},"text":${jstr(nfc(o.text))}}`).join(",")}],"recommendation":${jstr(nfc(c.recommendation))},"title":${jstr(nfc(c.title))}}`, "utf8");
|
||||
const optionsHash = (c: Shown): string => sha(canonical(c));
|
||||
|
||||
const cardRuleErrors = (c: Card): string[] => {
|
||||
const ids = c.options.map((o) => o.id);
|
||||
const errors: string[] = [];
|
||||
if (new Set(ids).size !== ids.length) errors.push("duplicate option ids");
|
||||
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.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");
|
||||
if (c.options_hash !== optionsHash(c)) errors.push("options_hash is not the hash of the shown members");
|
||||
return errors;
|
||||
};
|
||||
|
||||
test("fork card: options hash is SHA-256 of the canonical JSON, computed here, and every shown member moves it", () => {
|
||||
for (const h of fork.optionsHash) {
|
||||
const bytes = canonical(h);
|
||||
assert.equal(bytes.toString("utf8"), h.canonical, h.name);
|
||||
assert.equal(bytes.toString("hex").toUpperCase(), h.canonicalHex, h.name);
|
||||
assert.equal(sha(bytes), h.sha256, h.name);
|
||||
}
|
||||
const byName = new Map<string, string>(fork.optionsHash.map((h: { name: string; sha256: string }) => [h.name, h.sha256]));
|
||||
const base = byName.get("heimdall_A_five_members")!;
|
||||
assert.equal(byName.get("hidden_members_do_not_move_it"), base);
|
||||
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"]) {
|
||||
assert.notEqual(byName.get(moved), base, moved);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork card: mutating any shown member, an option id included, changes the hash", () => {
|
||||
const card = fork.card.valid[0].message as Card;
|
||||
const base = optionsHash(card);
|
||||
const mutations: ((c: Card) => void)[] = [
|
||||
(c) => { c.title += "x"; }, (c) => { c.context += "x"; }, (c) => { c.options[0].id += "x"; }, (c) => { c.options[0].text += "x"; },
|
||||
(c) => { c.options.reverse(); }, (c) => { c.recommendation = c.options[0].id; }, (c) => { c.depends = [...c.depends, "a:b:c"]; }, (c) => { c.depends = []; },
|
||||
];
|
||||
for (const [i, mutate] of mutations.entries()) {
|
||||
const copy = structuredClone(card);
|
||||
mutate(copy);
|
||||
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"];
|
||||
assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash");
|
||||
});
|
||||
|
||||
test("fork card: documents validate and encode to the vector bytes", () => {
|
||||
for (const v of fork.card.valid) {
|
||||
assert.ok(check(forkSchema, "card", v.message), v.name);
|
||||
assert.deepEqual(cardRuleErrors(v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
|
||||
for (const v of fork[section].valid) {
|
||||
assert.ok(check(forkSchema, def, v.message), v.name);
|
||||
assert.deepEqual(noteRuleErrors(def, v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
|
||||
}
|
||||
}
|
||||
const resultCodes = (forkSchema.$defs.deskAnswerRefused.properties.code as { enum: string[] }).enum;
|
||||
assert.deepEqual(fork.results.map((r: { name: string }) => r.name).sort(), [...resultCodes].sort());
|
||||
for (const v of fork.results) {
|
||||
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");
|
||||
}
|
||||
for (const v of fork.unavailable) {
|
||||
assert.ok(check(forkSchema, "unavailable", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.equal(v.httpStatus, 503, v.name);
|
||||
}
|
||||
const doorCodes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
assert.ok(!doorCodes.includes("unavailable"), "unavailable is kept apart from the refusal set");
|
||||
assert.deepEqual(fork.doorRefusals.map((r: { name: string }) => r.name).sort(), [...doorCodes].sort());
|
||||
for (const v of fork.doorRefusals) {
|
||||
assert.ok(check(forkSchema, "refusal", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork card: every negative is refused by the schema or a stated rule", () => {
|
||||
for (const v of fork.card.invalid) {
|
||||
assert.ok(!check(forkSchema, "card", v.message) || cardRuleErrors(v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
}
|
||||
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
|
||||
for (const v of fork[section].invalid) {
|
||||
assert.ok(!check(forkSchema, def, v.message) || noteRuleErrors(def, v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 TAILS: Record<string, string> = {
|
||||
unauthenticated: "a verified session is required",
|
||||
body_forbidden_field: "answerer and authority are set by the receiver",
|
||||
body_unknown_field: "the body is a closed shape",
|
||||
body_invalid: "the body does not satisfy gamecult.fork.answer_request.v0",
|
||||
fork_unknown: "no such fork",
|
||||
forbidden: "only Active members may use the desk",
|
||||
request_id_conflict: "this request_id was used for a different body",
|
||||
};
|
||||
const REQUEST_KEYS = ["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
|
||||
const hasKey = (v: unknown, names: string[]): boolean =>
|
||||
typeof v === "object" && v !== null && Object.entries(v).some(([k, x]) => names.includes(k) || hasKey(x, names));
|
||||
const SECONDS = (iso: string): number => Date.parse(iso);
|
||||
|
||||
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
|
||||
function judge(start: Card, steps: { kind: string; body: Record<string, any> | null; server: Record<string, any> | null; card?: Card }[]) {
|
||||
let card = start;
|
||||
const answers: Record<string, any>[] = [];
|
||||
const responses: unknown[] = [];
|
||||
const seen = new Map<string, { body: Record<string, any>; response: unknown }>(); // (answerer, request_id) -> the first stored outcome
|
||||
const counted = (): string | null => {
|
||||
const hash = optionsHash(card);
|
||||
const live = answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === hash
|
||||
&& !answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
|
||||
return live ? live.answer_id : null;
|
||||
};
|
||||
for (const step of steps) {
|
||||
if (step.kind === "matrix_event") { responses.push(null); continue; } // rule 9: Matrix never changes a decision
|
||||
if (step.kind === "card") { card = step.card!; responses.push(null); continue; }
|
||||
const s = step.server!, b = step.body!;
|
||||
const door = (code: string) => ({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
|
||||
const store = (outcome: string, link: string | null, code: string | null) => {
|
||||
const a = b.answer ?? { choice: null, note: null };
|
||||
const doc = { schemaVersion: "gamecult.desk_answer.v0", answer_id: s.answer_id, fork_id: b.fork_id, outcome,
|
||||
of: outcome === "Revision" ? link : null, retracts: outcome === "Retraction" ? link : null, code: outcome === "Refused" ? code : null,
|
||||
options_hash: b.options_hash, choice: a.choice, note: a.note, answered_at: b.answered_at, received_at: s.received_at,
|
||||
claim_in_future: SECONDS(b.answered_at) - SECONDS(s.received_at) > 300_000, answerer: s.answerer, authority: s.authority, request_id: b.request_id };
|
||||
answers.push(doc);
|
||||
const stored = { schemaVersion: "gamecult.fork.answer_result.v0", answer: doc, card: code === "stale_fork" ? card : null };
|
||||
seen.set(`${s.answerer}|${b.request_id}`, { body: structuredClone(b), response: structuredClone(stored) });
|
||||
return stored;
|
||||
};
|
||||
const refuse = (code: string) => store("Refused", null, code);
|
||||
const down = s.door_down === true, reached = s.stored === true; // the second door failed to answer; "stored" says whether Huginn had the request by then
|
||||
const unavailable = () => ({ schemaVersion: "gamecult.fork.unavailable.v0", code: "unavailable", error: "unavailable: the outcome is not confirmed, try again with the same request_id", retryAfterSeconds: s.retry_after_seconds });
|
||||
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
||||
const replay = (stored: any) => { const copy = structuredClone(stored); if (copy.answer.code === "stale_fork") copy.card = structuredClone(card); return copy; };
|
||||
let response: unknown;
|
||||
if (s.answerer === null) response = door("unauthenticated");
|
||||
else if (s.active === false) response = door("forbidden");
|
||||
else if (hasKey(b, ["answerer", "authority"])) response = door("body_forbidden_field");
|
||||
else if (Object.keys(b).some((k) => !REQUEST_KEYS.includes(k)) || (b.answer && Object.keys(b.answer).some((k) => k !== "choice" && k !== "note"))) response = door("body_unknown_field");
|
||||
else if (!check(forkSchema, "answerRequest", b) || noteRuleErrors("answerRequest", b).length > 0 || (!b.retracted && b.answer === null)) response = door("body_invalid");
|
||||
else if (down && !reached) response = unavailable();
|
||||
else if (seen.has(`${s.answerer}|${b.request_id}`)) { const prior = seen.get(`${s.answerer}|${b.request_id}`)!; response = isDeepStrictEqual(prior.body, b) ? replay(prior.response) : door("request_id_conflict"); }
|
||||
else if (b.fork_id !== card.fork_id) response = door("fork_unknown");
|
||||
else if (b.options_hash !== optionsHash(card)) response = refuse("stale_fork");
|
||||
else if (!card.deciders.includes(s.answerer)) response = refuse("not_a_decider");
|
||||
else if (card.status === "closed") response = refuse("fork_closed");
|
||||
else if (b.retracted || b.retracts !== null) {
|
||||
const target = answers.find((a) => a.answer_id === b.retracts && (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === b.options_hash);
|
||||
if (!b.retracted || b.answer !== null || b.retracts === null || !target) response = refuse("bad_retraction");
|
||||
else if (answers.some((a) => a.outcome === "Retraction" && a.retracts === target.answer_id)) response = refuse("already_retracted");
|
||||
else response = store("Retraction", target.answer_id, null);
|
||||
} else if (b.answer.choice !== null && !card.options.some((o) => o.id === b.answer.choice)) response = refuse("choice_invalid");
|
||||
else {
|
||||
const first = counted();
|
||||
response = first === null ? store("Counted", null, null) : store("Revision", first, null);
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
|
||||
test("fork card: every scenario replays to the vector's responses and record, bytes included", () => {
|
||||
for (const sc of fork.scenarios) {
|
||||
assert.equal(optionsHash(sc.card), sc.optionsHash, sc.name);
|
||||
assert.deepEqual(cardRuleErrors(sc.card), [], sc.name);
|
||||
const { responses, record } = judge(sc.card, sc.steps);
|
||||
assert.deepEqual(responses, sc.steps.map((s: { response: unknown }) => s.response), sc.name);
|
||||
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) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork card: a request id belongs to its account; another account's identical body gets its own answer, never the first account's outcome", () => {
|
||||
let crossed = 0;
|
||||
for (const sc of fork.scenarios) {
|
||||
const first = new Map<string, { answerer: string; result: any }>(); // request_id -> the account that first got a stored result and what it got
|
||||
for (const s of sc.steps) {
|
||||
if (s.kind !== "answer" || !s.response?.answer) continue;
|
||||
assert.equal(s.response.answer.answerer, s.server.answerer, `${sc.name}: a result is stamped with the account that asked`);
|
||||
const prior = first.get(s.body.request_id);
|
||||
if (!prior) { first.set(s.body.request_id, { answerer: s.server.answerer, result: s.response }); continue; }
|
||||
if (prior.answerer === s.server.answerer) continue;
|
||||
crossed++;
|
||||
assert.notEqual(s.response.answer.answer_id, prior.result.answer.answer_id, `${sc.name}: its own answer id`);
|
||||
assert.notDeepEqual(s.response, prior.result, `${sc.name}: the first account's outcome is never returned`);
|
||||
}
|
||||
}
|
||||
assert.ok(crossed >= 3, "the vectors carry the cross-account cases");
|
||||
});
|
||||
|
||||
test("fork card: a body refused at the door or naming an unknown fork is never stored", () => {
|
||||
const door = new Set(["unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict"]);
|
||||
let doorRefusals = 0;
|
||||
for (const sc of fork.scenarios) {
|
||||
for (const s of sc.steps) {
|
||||
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
|
||||
if (s.response?.code === "unavailable") assert.equal(sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), s.server.stored === true, `${sc.name} unavailable`);
|
||||
if (s.response && door.has(s.response.code)) {
|
||||
doorRefusals++;
|
||||
assert.ok(!sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), `${sc.name} stored a door refusal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(doorRefusals >= 8);
|
||||
});
|
||||
|
||||
test("fork card: the scenarios cover every outcome and every refusal code", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const sc of fork.scenarios) {
|
||||
for (const s of sc.steps) if (s.response) seen.add(s.response.code ?? s.response.answer.code ?? s.response.answer.outcome);
|
||||
for (const a of sc.record.answers) seen.add(a.outcome);
|
||||
}
|
||||
for (const want of ["Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork", "not_a_decider",
|
||||
"fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]) {
|
||||
assert.ok(seen.has(want), want);
|
||||
}
|
||||
});
|
||||
|
||||
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
|
||||
const admitRuling = (cite: { authority: string; answer_id: string | null }, answers: { answer_id: string; outcome: string; authority: string }[]): boolean => {
|
||||
if (cite.authority !== "Operator") return true;
|
||||
const cited = answers.find((a) => a.answer_id === cite.answer_id);
|
||||
return cited !== undefined && cited.outcome === "Counted" && cited.authority === "Operator";
|
||||
};
|
||||
|
||||
test("fork card: operator authority needs a cited Counted answer record", () => {
|
||||
for (const c of fork.rulingCites) {
|
||||
assert.ok(check(forkSchema, "rulingCite", c.message), c.name);
|
||||
assert.equal(hexOf(c.message), c.messagePackHex, c.name);
|
||||
assert.equal(admitRuling(c.message, c.answers), c.admit, c.name);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 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[] => {
|
||||
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");
|
||||
return errors;
|
||||
};
|
||||
|
||||
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
||||
const judgeList = (entries: { card: Card }[], ordinal: number, rq: { mind: string; status: string | null; cursor: string | null; limit: number }) => {
|
||||
const rows = [...entries].sort((a, b) => (b.card as any).order - (a.card as any).order);
|
||||
let want = rows.filter((e) => rq.status === null || (e.card.status === "open") === (rq.status === "open"));
|
||||
if (rq.cursor !== null) want = want.filter((e) => (e.card as any).order < Number(rq.cursor!.slice(1)));
|
||||
const page = want.slice(0, rq.limit);
|
||||
const more = want.length > page.length && page.length > 0;
|
||||
return { schemaVersion: "gamecult.fork.list.v0", mind: rq.mind, stamp: `s${ordinal}`, open_count: rows.filter((e) => e.card.status === "open").length,
|
||||
recorded_count: rows.filter((e) => e.card.status === "closed").length, forks: page, next_cursor: more ? `c${(page[page.length - 1].card as any).order}` : null };
|
||||
};
|
||||
|
||||
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
|
||||
const authJudge = (route: string, token: { audience: string; scopes: string[]; active: boolean } | null, huginnDown: boolean): string => {
|
||||
if (token === null || token.audience !== "bifrost-desk") return "unauthenticated";
|
||||
if (!token.scopes.includes(route === "answer" ? "desk.answer" : "desk.read")) return "insufficient_scope";
|
||||
if (!token.active) return "forbidden";
|
||||
return huginnDown ? "unavailable" : "ok";
|
||||
};
|
||||
|
||||
// The {mind} segment is decoded once and compared, exactly, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
|
||||
type MindResult = { code: string; opened: string | null; storedIn: string | null };
|
||||
const mindRoute = (config: any, route: string, segment: string, fork: string | null, token: any): MindResult => {
|
||||
const auth = authJudge(route, token, false);
|
||||
if (auth !== "ok") return { code: auth, opened: null, storedIn: null };
|
||||
let mind: string;
|
||||
try { mind = decodeURIComponent(segment); } catch { return { code: "fork_unknown", opened: null, storedIn: null }; }
|
||||
if (!Object.prototype.hasOwnProperty.call(config.minds, mind)) return { code: "fork_unknown", opened: null, storedIn: null };
|
||||
const opened: string = config.minds[mind];
|
||||
if (route !== "list" && !config.forks[mind].includes(fork)) return { code: "fork_unknown", opened, storedIn: null };
|
||||
return { code: "ok", opened, storedIn: route === "answer" ? mind : null };
|
||||
};
|
||||
|
||||
test("fork transport: the mind segment is only compared with the configured list; an unknown mind is the unknown fork refusal", () => {
|
||||
const config = fork.transport.mindRoutes;
|
||||
const refusals = new Map<string, any>(fork.doorRefusals.map((r: { name: string }) => [r.name, r]));
|
||||
const byName = new Map<string, MindResult>();
|
||||
for (const c of config.cases) {
|
||||
const got = mindRoute(config, c.route, c.mind, c.fork_id, c.token);
|
||||
byName.set(c.name, got);
|
||||
assert.equal(got.code, c.expect, c.name);
|
||||
assert.equal(got.opened, c.opened, `${c.name}: the socket opened`);
|
||||
assert.equal(got.storedIn, c.stored_in, `${c.name}: where an answer was stored`);
|
||||
assert.equal(c.httpStatus, got.code === "ok" ? 200 : refusals.get(got.code).httpStatus, c.name);
|
||||
if (got.code === "fork_unknown") {
|
||||
const error: string = refusals.get("fork_unknown").message.error;
|
||||
assert.ok(!error.includes(c.mind) && !(c.fork_id !== null && error.includes(c.fork_id)), `${c.name}: the refusal names neither the Mind nor the fork`);
|
||||
}
|
||||
}
|
||||
for (const [a, b] of config.sameResponse) {
|
||||
const x = byName.get(a)!, y = byName.get(b)!;
|
||||
assert.equal(x.code, y.code);
|
||||
const built = (code: string) => hexOf({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
|
||||
assert.equal(built(x.code), built(y.code), `${a} and ${b} answer with the same bytes, built from the spec's rule`);
|
||||
assert.equal(built(x.code), refusals.get(x.code).messagePackHex, a);
|
||||
}
|
||||
const all = [...byName.values()];
|
||||
assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 39, "every bad segment opens nothing");
|
||||
assert.equal(new Set(all.filter((v) => v.storedIn !== null).map((v) => v.storedIn)).size, 2, "an answer is stored in the route's Mind only, and both Minds take one");
|
||||
});
|
||||
|
||||
test("fork transport: requests and entries validate and encode to the vector bytes, and every negative is refused", () => {
|
||||
const t = fork.transport;
|
||||
for (const [section, def] of [["listRequest", "listRequest"], ["readRequest", "readRequest"], ["entry", "entry"]] as const) {
|
||||
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);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const v of t[section].invalid) {
|
||||
assert.ok(!check(forkSchema, def, v.message) || (def === "entry" && entryRuleErrors(v.message).length > 0), `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
}
|
||||
for (const v of t.listInvalid) {
|
||||
assert.equal(check(forkSchema, "list", v.message), false, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => {
|
||||
for (const sc of fork.transport.listScenarios) {
|
||||
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
|
||||
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);
|
||||
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`);
|
||||
previous = response;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => {
|
||||
const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
const seen = new Set<string>();
|
||||
for (const c of fork.transport.authCases) {
|
||||
const got = authJudge(c.route, c.token, c.huginn_down);
|
||||
assert.equal(got, c.expect, c.name);
|
||||
if (got !== "ok" && got !== "unavailable") assert.ok(codes.includes(got), c.name);
|
||||
seen.add(`${c.route}:${got}`);
|
||||
}
|
||||
for (const want of ["list:insufficient_scope", "answer:insufficient_scope", "read:forbidden", "list:unavailable", "read:unavailable", "answer:unavailable", "read:unauthenticated", "answer:ok"]) assert.ok(seen.has(want), want);
|
||||
});
|
||||
551
scripts/gen-governance-vectors.py
Normal file
551
scripts/gen-governance-vectors.py
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Writes contracts/cultnet/governance/{vote-api,fork-card}-vectors.json.
|
||||
|
||||
The bytes are spec-authored with msgpack-python (minimal ints, str8 only past 31 bytes, map keys in the order written here);
|
||||
each runtime's conformance test re-encodes the `message` and must produce the same hex. Run: python3 scripts/gen-governance-vectors.py
|
||||
Needs: pip install msgpack.
|
||||
"""
|
||||
import hashlib, json, pathlib, unicodedata
|
||||
import msgpack
|
||||
|
||||
OUT = pathlib.Path(__file__).resolve().parent.parent / "contracts" / "cultnet" / "governance"
|
||||
def hx(m): return msgpack.packb(m, use_bin_type=True).hex().upper()
|
||||
def sha(b): return hashlib.sha256(b).hexdigest()
|
||||
|
||||
M1 = "5b1c2e9a-3f4d-4c8e-9a61-0d7e2f4b8c13"
|
||||
M2 = "a7d3f0c2-6b15-4e29-8f3a-91c4d5e6f708"
|
||||
V1 = "0c9f6a4e-7d2b-4a35-b8e1-3f5a6c7d8e90"
|
||||
T0 = "2026-11-12T18:00:00Z"; T1 = "2026-11-15T18:00:00Z"; T2 = "2026-11-18T18:00:00Z"
|
||||
SALT = "d41d8cd98f00b204e9800998ecf8427e5f1c0a7b3e92d6a48b15c7f0e3a9d2b6"
|
||||
|
||||
# ---------- vote API ----------
|
||||
def commitment_bytes(motion, voter, persona, rnd, candidate, reason, salt):
|
||||
return f"gamecult.sealed_ballot.v0\n{motion}\n{voter}\n{persona}\n{rnd}\n{candidate}\n{reason}\n{salt}".encode("utf-8")
|
||||
|
||||
def ballot(motion=M1, rnd=1, candidate="Adopt the ledger", reason="It keeps the books honest.", salt=SALT, persona=""):
|
||||
return {"schema": "gamecult.sealed_ballot.v0", "motionId": motion, "round": rnd, "candidate": candidate,
|
||||
"reason": reason, "salt": salt, "persona": persona}
|
||||
def digest(b, voter=V1):
|
||||
return sha(commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"]))
|
||||
|
||||
B_MEMBER = ballot()
|
||||
B_PERSONA = ballot(persona="libby", candidate="Defer to round two", reason="The sealed window is too short to weigh both.")
|
||||
C_MEMBER = digest(B_MEMBER); C_PERSONA = digest(B_PERSONA)
|
||||
|
||||
def receipt(commitment, revision=1, revealed=False, motion=M1):
|
||||
return {"schemaVersion": "gamecult.vote.receipt.v0", "motionId": motion, "voterUserAccountId": V1,
|
||||
"commitment": commitment, "revision": revision, "committedAtUtc": "2026-11-12T19:04:11.1234567Z", "revealed": revealed}
|
||||
def motion(phase, your=None, mid=M1, high=False):
|
||||
return {"schemaVersion": "gamecult.vote.motion.v0", "id": mid, "title": "Ledger or lantern", "summary": "Pick one.",
|
||||
"round": 1, "priorMotionId": None, "ballotSchema": "gamecult.sealed_ballot.v0", "phase": phase,
|
||||
"candidates": ["Adopt the ledger", "Defer to round two"], "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high,
|
||||
"yourVoterId": V1, "yourCommitment": your, "announcement": None}
|
||||
def cast_commit(c, motion=M1): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": motion, "verb": "commit", "commitment": c, "ballot": None}
|
||||
def cast_reveal(b): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": b["motionId"], "verb": "reveal", "commitment": None, "ballot": b}
|
||||
def refusal(code, error, retry=None): return {"schemaVersion": "gamecult.vote.refusal.v0", "code": code, "error": error, "retryAfterSeconds": retry}
|
||||
def item(i, title, phase, high=False): return {"id": i, "title": title, "round": 1, "phase": phase, "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high}
|
||||
|
||||
valid = [
|
||||
("motion_list_request_all", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": None}),
|
||||
("motion_list_request_reveal", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": "Reveal"}),
|
||||
("motion_list_two", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [item(M1, "Ledger or lantern", "Commit"), item(M2, "Second thoughts", "Closed", True)]}),
|
||||
("motion_list_empty", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": []}),
|
||||
("motion_read_request", "motionReadRequest", {"schemaVersion": "gamecult.vote.motion_read_request.v0", "motionId": M1}),
|
||||
("motion_commit_phase_no_receipt", "motion", motion("Commit")),
|
||||
("motion_high_stakes_commit_phase", "motion", motion("Commit", high=True)),
|
||||
("motion_reveal_phase_committed", "motion", motion("Reveal", receipt(C_MEMBER))),
|
||||
("motion_closed_revealed", "motion", motion("Closed", receipt(C_MEMBER, 2, True))),
|
||||
("cast_reveal_reason_140_astral_280_units", "cast", cast_reveal(ballot(reason="\U0001F600" * 140))),
|
||||
("cast_commit", "cast", cast_commit(C_MEMBER)),
|
||||
("cast_reveal_member", "cast", cast_reveal(B_MEMBER)),
|
||||
("cast_reveal_persona", "cast", cast_reveal(B_PERSONA)),
|
||||
("receipt_committed", "receipt", receipt(C_MEMBER)),
|
||||
("receipt_revealed_echoes_no_ballot", "receipt", receipt(C_MEMBER, 2, True)),
|
||||
]
|
||||
refusals = [
|
||||
("unauthenticated", 401, "no bearer, or one Heimdall does not know", refusal("unauthenticated", "A bearer token is required.")),
|
||||
("reauthentication_required", 401, "a commit on a high-stakes motion with no sign-in fresh enough (Bifrost PR #37, D2); the voter signs in again at the top level and retries", refusal("reauthentication_required", "Sign in again to commit on this motion.")),
|
||||
("forbidden", 403, "authenticated, not an Active member, or a Mind key used on a motion route it may not call", refusal("forbidden", "Only Active members may vote.")),
|
||||
("not_found", 404, "unknown motion id, a standard (non-sealed) motion id, or reveal without a prior commit", refusal("not_found", "No such sealed-ballot motion.")),
|
||||
("wrong_phase", 409, "commit outside Commit, reveal outside Reveal", refusal("wrong_phase", "The commit window is not open.")),
|
||||
("contended", 409, "reveal raced the close fence; send it again", refusal("contended", "The reveal is contended; send it again.")),
|
||||
("invalid", 400, "body fails vote-api.schema.json or an in-document rule (untrimmed candidate or reason, candidate not in the motion, round or motionId mismatch, persona wrong for the credential)", refusal("invalid", "The cast does not satisfy gamecult.vote.cast.v0.")),
|
||||
("commitment_mismatch", 400, "reveal whose canonical bytes do not hash to the stored commitment", refusal("commitment_mismatch", "The ballot does not match the stored commitment.")),
|
||||
("payload_too_large", 413, "body over 4096 bytes", refusal("payload_too_large", "The body exceeds 4096 bytes.")),
|
||||
("held", 503, "the Ratatoskr voting brake is set (Bifrost PR #37, D5): no cast is read or stored while it is", refusal("held", "Voting is held.")),
|
||||
("rate_limited", 429, "more than the per-account cast rate; Retry-After mirrors retryAfterSeconds", refusal("rate_limited", "Too many casts.", 30)),
|
||||
]
|
||||
def tweak(d, **kw):
|
||||
d = json.loads(json.dumps(d));
|
||||
for k, v in kw.items():
|
||||
if k.startswith("b_"): d["ballot"][k[2:]] = v
|
||||
else: d[k] = v
|
||||
return d
|
||||
rev = cast_reveal(B_MEMBER)
|
||||
invalid = [
|
||||
("cast_commit_carries_a_ballot", "cast", tweak(cast_commit(C_MEMBER), ballot=B_MEMBER), "a commit names exactly the commitment; a ballot beside it would put content on the wire before the reveal"),
|
||||
("cast_reveal_carries_a_commitment", "cast", tweak(rev, commitment=C_MEMBER), "a reveal names exactly the ballot"),
|
||||
("cast_commit_without_commitment", "cast", tweak(cast_commit(C_MEMBER), commitment=None), "commit needs its hash"),
|
||||
("cast_verb_unknown", "cast", tweak(rev, verb="recast"), "verb is commit or reveal"),
|
||||
("cast_commitment_uppercase", "cast", cast_commit(C_MEMBER.upper()), "commitment is lowercase hex"),
|
||||
("cast_commitment_63_chars", "cast", cast_commit(C_MEMBER[:63]), "commitment is 64 hex characters"),
|
||||
("cast_unknown_key", "cast", tweak(cast_commit(C_MEMBER), note="hi"), "closed document"),
|
||||
("cast_motion_id_uppercase", "cast", cast_commit(C_MEMBER, M1.upper()), "ids are lowercase D-format uuids"),
|
||||
("ballot_reason_empty", "cast", tweak(rev, b_reason=""), "reason is 1 to 280 characters"),
|
||||
("ballot_reason_281", "cast", tweak(rev, b_reason="x" * 281), "reason is at most 280 characters"),
|
||||
("ballot_reason_141_astral_282_units", "cast", tweak(rev, b_reason="\U0001F600" * 141), "reason is at most 280 UTF-16 code units (vote-api.md); the schema counts code points and accepts this, so a rule refuses it"),
|
||||
("ballot_reason_multiline", "cast", tweak(rev, b_reason="one\ntwo"), "reason is a single line"),
|
||||
("ballot_reason_leading_space", "cast", tweak(rev, b_reason=" because"), "the contract refuses untrimmed text; it never trims for the sender, so the hashed bytes are the sent bytes"),
|
||||
("ballot_candidate_trailing_nbsp", "cast", tweak(rev, b_candidate="Adopt the ledger "), "NO-BREAK SPACE is White_Space; trimming is judged by the same list as write-receipt Required fields"),
|
||||
("ballot_salt_31_hex", "cast", tweak(rev, b_salt=SALT[:31]), "salt is 32 to 128 lowercase hex characters"),
|
||||
("ballot_salt_uppercase", "cast", tweak(rev, b_salt=SALT.upper()), "salt is lowercase hex"),
|
||||
("ballot_schema_wrong", "cast", tweak(rev, b_schema="gamecult.sealed_ballot.v1"), "ballot schema is gamecult.sealed_ballot.v0"),
|
||||
("ballot_persona_with_space", "cast", tweak(rev, b_persona="my mind"), "persona is printable ASCII without space, at most 64"),
|
||||
("ballot_round_zero", "cast", tweak(rev, b_round=0), "rounds start at 1"),
|
||||
("receipt_echoes_candidate", "receipt", dict(receipt(C_MEMBER, 2, True), candidate="Adopt the ledger"), "sealed casts never echo ballot content; the receipt is closed"),
|
||||
("receipt_echoes_salt", "receipt", dict(receipt(C_MEMBER, 2, True), salt=SALT), "the salt never comes back before close"),
|
||||
("receipt_revision_zero", "receipt", receipt(C_MEMBER, 0), "revision starts at 1"),
|
||||
("receipt_time_without_zone", "receipt", dict(receipt(C_MEMBER), committedAtUtc="2026-11-12T19:04:11"), "instants are UTC with a Z"),
|
||||
("motion_one_candidate", "motion", dict(motion("Commit"), candidates=["Only"]), "a motion has two or more candidates"),
|
||||
("motion_phase_unknown", "motion", dict(motion("Commit"), phase="Open"), "phase is Commit, Reveal or Closed"),
|
||||
("motion_high_stakes_not_boolean", "motion", dict(motion("Commit"), highStakes="yes"), "highStakes is a boolean, fixed at open"),
|
||||
("motion_without_high_stakes", "motion", {k: v for k, v in motion("Commit").items() if k != "highStakes"}, "map-mode writes every key; a motion states whether it is high-stakes"),
|
||||
("motion_list_item_without_high_stakes", "motionListItem", {k: v for k, v in item(M1, "x", "Commit").items() if k != "highStakes"}, "every list item states whether the motion is high-stakes"),
|
||||
("motion_leaks_tally", "motion", dict(motion("Reveal"), tally=[]), "mid-vote a client sees no tallies; the document is closed"),
|
||||
("motion_list_item_without_phase", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [{k: v for k, v in item(M1, "x", "Commit").items() if k != "phase"}]}, "every item names its phase"),
|
||||
("refusal_code_unknown", "refusal", refusal("teapot", "no"), "the code set is closed"),
|
||||
("refusal_without_error", "refusal", dict(refusal("invalid", "x"), error=""), "error is non-empty"),
|
||||
]
|
||||
pairs = [
|
||||
{"name": "member_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": True},
|
||||
{"name": "persona_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(B_PERSONA), "matches": True},
|
||||
{"name": "reveal_with_changed_reason_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(ballot(reason="It keeps the books honest!")), "matches": False, "refusal": "commitment_mismatch"},
|
||||
{"name": "reveal_under_another_voter_does_not_match", "voterUserAccountId": M2, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": False, "refusal": "commitment_mismatch"},
|
||||
{"name": "persona_dropped_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(ballot(persona="", candidate=B_PERSONA["candidate"], reason=B_PERSONA["reason"])), "matches": False, "refusal": "commitment_mismatch"},
|
||||
]
|
||||
commitments = []
|
||||
for name, b, voter in [("member", B_MEMBER, V1), ("persona", B_PERSONA, V1), ("non_ascii", ballot(candidate="Café ☕", reason="naïve – but fine"), V1),
|
||||
("decomposed_e_is_another_ballot", ballot(candidate="Café ☕", reason="naïve – but fine"), V1)]:
|
||||
cb = commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"])
|
||||
commitments.append({"name": name, "voterUserAccountId": voter, "ballot": b, "canonicalHex": cb.hex().upper(), "sha256": sha(cb)})
|
||||
|
||||
vote = {
|
||||
"provenance": "Spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. Commitment hashes follow vote-api.md 'Commitment'.",
|
||||
"commitment": commitments,
|
||||
"valid": [{"name": n, "def": d, "message": m, "messagePackHex": hx(m)} for n, d, m in valid],
|
||||
"invalid": [{"name": n, "def": d, "message": m, "why": w, "messagePackHex": hx(m)} for n, d, m, w in invalid],
|
||||
"pairs": pairs,
|
||||
"refusals": [{"name": n, "httpStatus": s, "trigger": t, "message": m, "messagePackHex": hx(m)} for n, s, t, m in refusals],
|
||||
}
|
||||
(OUT / "vote-api-vectors.json").write_text(json.dumps(vote, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
# ---------- fork card ----------
|
||||
# Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), sections 1.1 to 1.5 and 1.3a. The seven hash
|
||||
# vectors A to G below are that file's, with its expected digests asserted.
|
||||
def jstr(x):
|
||||
out = ['"']
|
||||
for ch in x:
|
||||
o = ord(ch)
|
||||
if ch == '"': out.append('\\"')
|
||||
elif ch == "\\": out.append("\\\\")
|
||||
elif ch == "\b": out.append("\\b")
|
||||
elif ch == "\t": out.append("\\t")
|
||||
elif ch == "\n": out.append("\\n")
|
||||
elif ch == "\f": out.append("\\f")
|
||||
elif ch == "\r": out.append("\\r")
|
||||
elif o < 0x20: out.append("\\u%04x" % o)
|
||||
else: out.append(ch)
|
||||
out.append('"')
|
||||
return "".join(out)
|
||||
def nfc(x): return unicodedata.normalize("NFC", x)
|
||||
def canonical_json(title, context, options, recommendation, depends):
|
||||
"""RFC 8785 over {context, depends, options:[{id,text}], recommendation, title} after NFC on every string; keys sorted; no numbers. (None is accepted only to check Heimdall's null digests.)"""
|
||||
opts = ",".join('{"id":%s,"text":%s}' % (jstr(nfc(o["id"])), jstr(nfc(o["text"]))) for o in options)
|
||||
dep = ",".join(jstr(nfc(d)) for d in depends)
|
||||
rec = "null" if recommendation is None else jstr(nfc(recommendation))
|
||||
return ('{"context":%s,"depends":[%s],"options":[%s],"recommendation":%s,"title":%s}' % (jstr(nfc(context)), dep, opts, rec, jstr(nfc(title)))).encode("utf-8")
|
||||
|
||||
OP = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f"; ST = "11111111-2222-4333-8444-555555555555"; HM = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a"
|
||||
FID = "gamecult-chat:question:slot-mark-consent"
|
||||
OPT = [{"id": "yes", "text": "Share it."}, {"id": "no", "text": "Keep it local."}]
|
||||
DEP = ["gamecult-chat:question:file-zone-audience"]
|
||||
def chash(c): return sha(canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"]))
|
||||
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"}, "deciders": [OP],
|
||||
"status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""}
|
||||
c.update(kw); c["options_hash"] = chash(c); return c
|
||||
CARD = card(); H = CARD["options_hash"]
|
||||
|
||||
hashes = []
|
||||
CLAIMED = {"A": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "B": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc",
|
||||
"C": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5", "D": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272",
|
||||
"E": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272", "F": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40",
|
||||
"G": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"}
|
||||
def hv(name, c, heimdall=None):
|
||||
b = canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"])
|
||||
if heimdall: print("heimdall", heimdall, "same" if CLAIMED[heimdall] == sha(b) else f"DIFFERS mine={sha(b)} his={CLAIMED[heimdall]}")
|
||||
hashes.append({"name": name, "heimdall": heimdall, "title": c["title"], "context": c["context"], "options": c["options"],
|
||||
"recommendation": c["recommendation"], "depends": c["depends"], "canonical": b.decode("utf-8"),
|
||||
"canonicalHex": b.hex().upper(), "sha256": sha(b)})
|
||||
CD = dict(title="Caf\u00e9 \u201cdesk\u201d", context="Line one\nLine two \u2014 \U0001F600", options=[{"id": "a", "text": "x"}, {"id": "b", "text": "y"}], recommendation="a", depends=[])
|
||||
hv("heimdall_A_five_members", CARD, "A")
|
||||
hv("heimdall_B_recommendation_changed", card(recommendation="yes"), "B")
|
||||
hv("heimdall_C_option_order_changed", card(options=[OPT[1], OPT[0]]), "C")
|
||||
hv("heimdall_D_nfc_escapes_astral", card(**CD), "D")
|
||||
hv("heimdall_E_decomposed_equals_D", card(**dict(CD, title="Cafe\u0301 \u201cdesk\u201d")), "E")
|
||||
hv("heimdall_F_option_id_changed", card(options=[{"id": "share", "text": "Share it."}, OPT[1]]), "F")
|
||||
hv("heimdall_G_depends_emptied", card(depends=[]), "G")
|
||||
hv("option_text_changed", card(options=[dict(OPT[0], text="Share it!"), OPT[1]]))
|
||||
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, 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"]
|
||||
assert len({h["sha256"] for h in hashes if h["name"] in ("heimdall_A_five_members", "hidden_members_do_not_move_it")}) == 1
|
||||
|
||||
RID = [0]
|
||||
def next_rid():
|
||||
RID[0] += 1; n = RID[0]
|
||||
return f"{n:08x}-0000-4000-8000-{n:012x}"
|
||||
def body(choice=None, note=None, h=None, at="2026-11-12T19:02:00Z", fork=FID, retracts=None, answer="auto", rid=None):
|
||||
a = {"choice": choice, "note": note} if answer == "auto" else answer
|
||||
if answer == "auto" and choice is None and note is None: a = None
|
||||
return {"schemaVersion": "gamecult.fork.answer_request.v0", "fork_id": fork, "options_hash": h or H, "answer": a,
|
||||
"retracted": retracts is not None, "retracts": retracts, "answered_at": at, "request_id": rid or next_rid()}
|
||||
def retraction(target, **kw): return body(retracts=target, **kw)
|
||||
NOTE = "Keep it local: a slot mark is the author's call, and sharing it is a separate act."
|
||||
def srv(aid, who, at, auth="Operator"): return {"answer_id": aid, "answerer": who, "authority": auth, "received_at": at}
|
||||
TAIL = {"unauthenticated": "a verified session is required", "body_forbidden_field": "answerer and authority are set by the receiver", "body_unknown_field": "the body is a closed shape",
|
||||
"body_invalid": "the body does not satisfy gamecult.fork.answer_request.v0", "fork_unknown": "no such fork",
|
||||
"insufficient_scope": "the token lacks the scope this route needs", "forbidden": "only Active members may use the desk", "request_id_conflict": "this request_id was used for a different body", "request_invalid": "the request does not satisfy its document"}
|
||||
DOOR = set(TAIL)
|
||||
def refusal_doc(code): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": code, "error": f"{code}: {TAIL[code]}"}
|
||||
def unavailable_doc(retry=30): return {"schemaVersion": "gamecult.fork.unavailable.v0", "code": "unavailable", "error": "unavailable: the outcome is not confirmed, try again with the same request_id", "retryAfterSeconds": retry}
|
||||
def future(a, r):
|
||||
import datetime as dt
|
||||
p = lambda x: dt.datetime.fromisoformat(x.replace("Z", "+00:00"))
|
||||
return (p(a) - p(r)).total_seconds() > 300
|
||||
def da(outcome, link, code, b, server):
|
||||
a = b["answer"] or {"choice": None, "note": None}
|
||||
return {"schemaVersion": "gamecult.desk_answer.v0", "answer_id": server["answer_id"], "fork_id": b["fork_id"], "outcome": outcome,
|
||||
"of": link if outcome == "Revision" else None, "retracts": link if outcome == "Retraction" else None, "code": code if outcome == "Refused" else None,
|
||||
"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}
|
||||
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}
|
||||
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
|
||||
def ok(b, server, outcome, of):
|
||||
a = da(outcome, of, None, b, server); return step("answer", b, server, result(a)), a
|
||||
def rf(b, server, code, c=None):
|
||||
a = da("Refused", None, code, b, server); return step("answer", b, server, result(a, c)), a
|
||||
def door(b, server, code): return step("answer", b, server, refusal_doc(code)), None
|
||||
def sc(name, c, items, counted, h=None):
|
||||
steps = [i[0] if isinstance(i, tuple) else i for i in items]
|
||||
answers = [i[1] for i in items if isinstance(i, tuple) and i[1] is not None]
|
||||
return (name, c, steps, record(counted, answers, h or c["options_hash"]))
|
||||
|
||||
x1 = body("no", NOTE); sx1 = srv("n1", OP, T(2))
|
||||
x2 = body("yes", "On reflection, share it.", at="2026-11-12T19:06:00Z"); sx2 = srv("n2", OP, T(6))
|
||||
y1 = body("yes", "Share it, the audience is settled.", at="2026-11-12T19:05:00Z"); sy1 = srv("n3", HM, T(5), auth="Steward")
|
||||
z1 = body("yes", "Share, please."); sz = srv("n4", ST, T(3), auth="Member")
|
||||
pick = body("no", None); words = body(None, NOTE)
|
||||
STALE_H = chash(card(recommendation="yes"))
|
||||
def stale_body(**kw): return body("no", NOTE, h=STALE_H, **kw)
|
||||
FORKC = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")
|
||||
RULED = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1", ruled_from=["n1"])
|
||||
Y_CARD = card(deciders=[OP, HM])
|
||||
fork_edit = card(title="Slot mark consent (revised)"); H2 = fork_edit["options_hash"]
|
||||
RET = lambda t: retraction(t, at="2026-11-12T19:09:00Z")
|
||||
def sr(aid, who=OP, m=9, auth="Operator"): return srv(aid, who, T(m), auth)
|
||||
MATRIX = [step("matrix_event", None, None, None, matrix_kind=k, choice=c) for k, c in (("m.replace", "yes"), ("m.redaction", None), ("m.reaction", "yes"))]
|
||||
X1 = ok(x1, sx1, "Counted", None)
|
||||
REPLAY1 = (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)
|
||||
LOST = [(step("answer", x1, dict(sx1, door_down=True, stored=True, retry_after_seconds=30), unavailable_doc(30)), X1[1])]
|
||||
STALE_BODY = stale_body(); STALE_RF = rf(STALE_BODY, sx1, "stale_fork", c=CARD)
|
||||
OFFLIST = rf(x1, srv("n1", ST, T(2), "Member"), "not_a_decider")
|
||||
OFFLIST_REPLAY = (step("answer", x1, srv("n9", ST, T(4), "Member"), OFFLIST[0]["response"]), None)
|
||||
scen = [
|
||||
sc("operator_answers_and_is_counted", CARD, [X1], "n1"),
|
||||
sc("choice_alone_is_counted", CARD, [ok(pick, sx1, "Counted", None)], "n1"),
|
||||
sc("note_alone_is_counted_a_clear_pick", CARD, [ok(words, sx1, "Counted", None)], "n1"),
|
||||
sc("V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", CARD, [door(dict(x1, answerer="x"), sx1, "body_forbidden_field")], None),
|
||||
sc("V1_authority_nested_in_answer_is_refused", CARD, [door(dict(x1, answer={"choice": "no", "note": None, "authority": "Operator"}), sx1, "body_forbidden_field")], None),
|
||||
sc("V1_both_are_refused_not_stripped", CARD, [door(dict(x1, answerer=OP, authority="Operator"), sx1, "body_forbidden_field")], None),
|
||||
sc("M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", CARD, [door(dict(x1, deciders=[ST]), sx1, "body_unknown_field")], None),
|
||||
sc("M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", CARD, [door(dict(x1, mind="nibu"), sx1, "body_unknown_field")], None),
|
||||
sc("an_unknown_key_is_refused_the_body_is_closed", CARD, [door(dict(x1, mood="happy"), sx1, "body_unknown_field")], None),
|
||||
sc("an_answer_with_neither_choice_nor_note_is_invalid", CARD, [door(body(answer={"choice": None, "note": None}), sx1, "body_invalid")], None),
|
||||
sc("a_non_retraction_without_an_answer_is_invalid", CARD, [door(body(answer=None), sx1, "body_invalid")], None),
|
||||
sc("unavailable_before_huginn_stored_nothing_is_retried_into_a_Counted_answer", CARD, [step("answer", x1, dict(sx1, answer_id="n9", door_down=True, retry_after_seconds=30), unavailable_doc(30)), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("unavailable_after_huginn_stored_the_retry_with_the_same_request_id_returns_the_stored_outcome", CARD, LOST + [REPLAY1], "n1"),
|
||||
sc("a_retry_never_adds_a_Revision", CARD, [X1, REPLAY1], "n1"),
|
||||
sc("Q1_a_replayed_stale_fork_returns_the_CURRENT_card_and_stores_nothing_new", CARD, [STALE_RF, step("card", None, None, None, card=fork_edit),
|
||||
(step("answer", STALE_BODY, dict(sx1, answer_id="n9"), result(STALE_RF[1], fork_edit)), None)], None, H2),
|
||||
sc("V13_only_answered_at_changed_is_request_id_conflict", CARD, [X1, door(dict(x1, answered_at="2026-11-12T19:03:00Z"), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
|
||||
sc("V14_an_identical_body_from_another_account_gets_its_own_Counted_and_never_the_first_accounts_outcome", Y_CARD, [OFFLIST, ok(x1, srv("n2", HM, T(3), "Steward"), "Counted", None), OFFLIST_REPLAY], "n2"),
|
||||
sc("V14_an_identical_body_from_another_decider_is_its_own_Revision_and_the_first_retry_still_returns_n1", Y_CARD, [X1, ok(x1, sy1, "Revision", "n1"), (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)], "n1"),
|
||||
sc("V16_Q5_a_forbidden_request_binds_no_request_id", CARD, [door(x1, dict(sx1, answer_id="n9", active=False), "forbidden"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("V17_Q6_a_body_refused_at_the_door_binds_no_request_id", CARD, [door(dict(x1, mood="happy"), dict(sx1, answer_id="n9"), "body_unknown_field"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("V17_Q6_an_unknown_fork_binds_no_request_id", CARD, [door(dict(x1, fork_id="gamecult-chat:question:nope"), dict(sx1, answer_id="n9"), "fork_unknown"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("the_same_request_id_with_another_body_is_request_id_conflict", CARD, [X1, door(dict(x1, answer={"choice": "yes", "note": None}), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
|
||||
sc("the_same_request_id_from_another_account_is_another_request", Y_CARD, [X1, ok(dict(y1, request_id=x1["request_id"]), sy1, "Revision", "n1")], "n1"),
|
||||
sc("a_non_active_member_is_forbidden_before_the_body_is_read", CARD, [door(dict(x1, answerer="x"), dict(sx1, active=False), "forbidden"), door(x1, dict(sx1, active=False), "forbidden"),
|
||||
door(dict(x1, mood="happy"), dict(sx1, active=False, door_down=True, retry_after_seconds=30), "forbidden")], None),
|
||||
sc("no_session_is_unauthenticated", CARD, [door(x1, srv("n1", None, T(2), None), "unauthenticated")], None),
|
||||
sc("an_unknown_fork_is_refused_at_the_door", CARD, [door(body("no", NOTE, fork="gamecult-chat:question:nope"), sx1, "fork_unknown")], None),
|
||||
sc("V2_stale_hash_is_refused_not_counted_and_stored_as_Refused", CARD, [STALE_RF], None),
|
||||
sc("V2_a_stale_answer_then_a_fresh_one_the_fresh_one_is_counted", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), ok(x1, sx2, "Counted", None)], "n2"),
|
||||
sc("V3_an_answer_off_the_decider_list_is_Refused_not_a_decider_and_stored", CARD, [rf(z1, sz, "not_a_decider")], None),
|
||||
sc("a_non_decider_with_a_stale_hash_is_stale_first", CARD, [rf(dict(z1, options_hash=STALE_H), sz, "stale_fork", c=CARD)], None),
|
||||
sc("V4_a_closed_fork_refuses_and_stores_the_refusal", FORKC, [rf(x1, sx1, "fork_closed")], None),
|
||||
sc("V5_second_answer_from_one_decider_is_a_Revision_the_first_stays_counted", CARD, [X1, ok(x2, sx2, "Revision", "n1")], "n1"),
|
||||
sc("V5_receiver_order_decides_not_answered_at", CARD, [ok(x2, srv("n1", OP, T(2)), "Counted", None), ok(x1, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("V5_reversed_arrival_reverses_the_result", CARD, [ok(x1, srv("n1", OP, T(2)), "Counted", None), ok(x2, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("a_second_decider_answer_is_recorded_as_a_Revision", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1")], "n1"),
|
||||
sc("a_claim_exactly_five_minutes_ahead_is_not_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05.500Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:09:00Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("V6_a_matrix_edit_redaction_or_reaction_changes_nothing", CARD, [X1] + MATRIX, "n1"),
|
||||
sc("V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", CARD, [rf(body("maybe", None), sx1, "choice_invalid")], None),
|
||||
sc("a_changed_card_stops_the_old_answers_counting_and_a_new_answer_is_counted", CARD,
|
||||
[X1, step("card", None, None, None, card=fork_edit), ok(body("no", NOTE, h=H2), sx2, "Counted", None)], "n2", H2),
|
||||
sc("a_changed_card_leaves_no_counted_answer_until_one_arrives", CARD, [X1, step("card", None, None, None, card=fork_edit)], None, H2),
|
||||
sc("V8_retraction_with_an_answer_is_bad", CARD, [X1, rf(dict(retraction("n1"), answer={"choice": "no", "note": None}), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_without_retracts_is_bad", CARD, [X1, rf(dict(retraction("n1"), retracts=None), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retracts_on_a_plain_answer_is_bad", CARD, [X1, rf(dict(x2, retracts="n1"), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_naming_an_unknown_answer_is_bad", CARD, [X1, rf(retraction("n99"), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_naming_a_Refused_answer_is_bad", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), rf(retraction("n1"), sr("n9"), "bad_retraction")], None),
|
||||
sc("V8_retracting_twice_is_already_retracted", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), rf(RET("n1"), sr("n9", m=10), "already_retracted")], None),
|
||||
sc("V8_retraction_under_a_stale_hash_is_stale_fork", CARD, [X1, rf(retraction("n1", h=STALE_H), sr("n9"), "stale_fork", c=CARD)], "n1"),
|
||||
sc("V9_another_decider_retracts_and_the_answer_stops_counting", Y_CARD, [X1, ok(RET("n1"), sr("n7", HM, 9, "Steward"), "Retraction", "n1")], None),
|
||||
sc("V9_an_account_off_the_list_cannot_retract", CARD, [X1, rf(RET("n1"), sr("n6", ST, 9, "Member"), "not_a_decider")], "n1"),
|
||||
sc("V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed", CARD, [X1, step("card", None, None, None, card=RULED), rf(RET("n1"), sr("n9"), "fork_closed")], "n1"),
|
||||
sc("V11_retracting_the_counted_answer_makes_the_next_decider_answer_counted", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n1"), sr("n8"), "Retraction", "n1")], "n3"),
|
||||
sc("V11_retracting_an_answer_that_was_not_counted_changes_nothing", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n3"), sr("n8"), "Retraction", "n3")], "n1"),
|
||||
sc("after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), ok(x2, sx2, "Counted", None)], "n2"),
|
||||
]
|
||||
REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]
|
||||
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400,
|
||||
"stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409}
|
||||
STORED_REFUSALS = []
|
||||
for reason in REASON_CODES:
|
||||
a = da("Refused", None, reason, x1, sx1)
|
||||
STORED_REFUSALS.append({"name": reason, "httpStatus": HTTP[reason], "message": result(a, CARD if reason == "stale_fork" else None)})
|
||||
# ruling citation: authority Operator must cite a Counted answer record whose answerer holds Operator authority
|
||||
CNT = da("Counted", None, None, x1, sx1); REV = da("Revision", "n1", None, x2, sx2)
|
||||
REF = da("Refused", None, "fork_closed", x1, srv("n5", OP, T(7))); NONOP = da("Counted", None, None, z1, sz)
|
||||
def cite(authority, ans, ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1"):
|
||||
return {"schemaVersion": "gamecult.fork.ruling_cite.v0", "ruling_id": ruling, "authority": authority, "answer_id": ans}
|
||||
cites = [
|
||||
{"name": "operator_cites_a_counted_operator_answer", "answers": [CNT], "message": cite("Operator", "n1"), "admit": True},
|
||||
{"name": "operator_cites_nothing", "answers": [CNT], "message": cite("Operator", None), "admit": False},
|
||||
{"name": "operator_cites_a_revision", "answers": [CNT, REV], "message": cite("Operator", "n2"), "admit": False},
|
||||
{"name": "operator_cites_a_refused_answer", "answers": [REF], "message": cite("Operator", "n5"), "admit": False},
|
||||
{"name": "operator_cites_a_counted_answer_by_a_non_operator", "answers": [NONOP], "message": cite("Operator", "n4"), "admit": False},
|
||||
{"name": "operator_cites_an_answer_that_does_not_exist", "answers": [CNT], "message": cite("Operator", "n99"), "admit": False},
|
||||
{"name": "steward_needs_no_citation", "answers": [], "message": cite("Steward", None), "admit": True},
|
||||
]
|
||||
for cz in cites: cz["messagePackHex"] = hx(cz["message"])
|
||||
# ---------- transport: list, read, auth ----------
|
||||
def fcard(n, status="open", **kw):
|
||||
c = card(fork_id=f"gamecult-chat:question:q{n}", title=f"Fork {n}", order=n, status=status, **kw)
|
||||
return c
|
||||
def fbody(c, choice, aid_note):
|
||||
return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"])
|
||||
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"])}
|
||||
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)]
|
||||
def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit}
|
||||
def judge_list(entries, ordinal, rq):
|
||||
rows = sorted(entries, key=lambda e: -e["card"]["order"])
|
||||
want = [e for e in rows if rq["status"] is None or (e["card"]["status"] == "open") == (rq["status"] == "open")]
|
||||
if rq["cursor"] is not None: want = [e for e in want if e["card"]["order"] < int(rq["cursor"][1:])]
|
||||
page = want[:rq["limit"]]
|
||||
more = len(want) > len(page) and len(page) > 0
|
||||
return {"schemaVersion": "gamecult.fork.list.v0", "mind": rq["mind"], "stamp": f"s{ordinal}",
|
||||
"open_count": sum(e["card"]["status"] == "open" for e in rows), "recorded_count": sum(e["card"]["status"] == "closed" for e in rows),
|
||||
"forks": page, "next_cursor": f"c{page[-1]['card']['order']}" if more else None}
|
||||
def lstep(entries, ordinal, rq): r = judge_list(entries, ordinal, rq); return {"request": rq, "response": r, "responseHex": hx(r)}
|
||||
LISTS = [
|
||||
{"name": "newest_first_paged_open_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="open", limit=2)), lstep(ENTRIES, 412, lreq(status="open", cursor="c4", limit=2))]},
|
||||
{"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]},
|
||||
{"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": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
|
||||
]
|
||||
TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active}
|
||||
AUTH = [
|
||||
("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"),
|
||||
("answer_with_desk_answer", "answer", TOK("bifrost-desk", "desk.answer"), "ok"), ("answer_with_both_scopes", "answer", TOK("bifrost-desk", "desk.read", "desk.answer"), "ok"),
|
||||
("no_bearer_list", "list", None, "unauthenticated"), ("no_bearer_answer", "answer", None, "unauthenticated"),
|
||||
("the_MAS_token_is_refused", "list", TOK("matrix-authentication-service", "openid", "profile"), "unauthenticated"),
|
||||
("a_votes_token_cannot_list", "list", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
|
||||
("a_votes_token_cannot_read", "read", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
|
||||
("a_votes_token_cannot_answer", "answer", TOK("bifrost-votes", "votes.cast"), "unauthenticated"),
|
||||
("desk_audience_with_a_votes_scope_only", "answer", TOK("bifrost-desk", "votes.cast"), "insufficient_scope"),
|
||||
("desk_read_cannot_answer", "answer", TOK("bifrost-desk", "desk.read"), "insufficient_scope"),
|
||||
("desk_answer_cannot_list", "list", TOK("bifrost-desk", "desk.answer"), "insufficient_scope"),
|
||||
("a_non_active_member_cannot_list", "list", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
|
||||
("a_non_active_member_cannot_read", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
|
||||
("a_non_active_member_cannot_answer", "answer", TOK("bifrost-desk", "desk.answer", active=False), "forbidden"),
|
||||
("scope_is_judged_before_membership", "answer", TOK("bifrost-desk", "desk.read", active=False), "insufficient_scope"),
|
||||
("huginn_down_list", "list", TOK("bifrost-desk", "desk.read"), "unavailable", True),
|
||||
("huginn_down_read", "read", TOK("bifrost-desk", "desk.read"), "unavailable", True),
|
||||
("huginn_down_answer", "answer", TOK("bifrost-desk", "desk.answer"), "unavailable", True),
|
||||
("auth_is_judged_before_huginn_is_asked", "list", None, "unauthenticated", True),
|
||||
("membership_is_judged_before_huginn_is_asked", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden", True),
|
||||
]
|
||||
# ---------- transport: the {mind} segment (Heimdall's file 4.3a, M1 to M4) ----------
|
||||
import urllib.parse
|
||||
MINDS = {"gamecult-chat": "huginn-gamecult-chat.sock", "nibu": "huginn-nibu.sock"}
|
||||
MIND_FORKS = {"gamecult-chat": ["gamecult-chat:question:q1", "gamecult-chat:question:q2", FID], "nibu": ["nibu:question:glow-order"]}
|
||||
def auth_code(route, tok):
|
||||
if tok is None or tok["audience"] != "bifrost-desk": return "unauthenticated"
|
||||
if ("desk.answer" if route == "answer" else "desk.read") not in tok["scopes"]: return "insufficient_scope"
|
||||
return "forbidden" if not tok["active"] else "ok"
|
||||
def mind_route(route, seg, fork, tok):
|
||||
code = auth_code(route, tok)
|
||||
if code != "ok": return code, None, None
|
||||
try: mind = urllib.parse.unquote(seg, errors="strict")
|
||||
except UnicodeDecodeError: mind = None
|
||||
if mind not in MINDS: return "fork_unknown", None, None # never joined into a path; no socket is opened
|
||||
if route != "list" and fork not in MIND_FORKS[mind]: return "fork_unknown", MINDS[mind], None
|
||||
return "ok", MINDS[mind], mind if route == "answer" else None
|
||||
DESK_READ, DESK_ANSWER = TOK("bifrost-desk", "desk.read"), TOK("bifrost-desk", "desk.answer")
|
||||
def mcase(name, route, seg, fork=None, tok=None):
|
||||
tok = None if tok == "none" else tok or (DESK_ANSWER if route == "answer" else DESK_READ)
|
||||
code, opened, stored = mind_route(route, seg, fork, tok)
|
||||
return {"name": name, "route": route, "mind": seg, "fork_id": fork, "token": tok, "expect": code, "httpStatus": 200 if code == "ok" else HTTP[code], "opened": opened, "stored_in": stored}
|
||||
BAD_SEGMENTS = [("dot_dot", "../x"), ("slash", "a/b"), ("encoded_slash", "a%2fb"), ("encoded_dot_dot", "%2e%2e%2fx"), ("nul", "eureka%00"), ("four_kb", "a" * 4096),
|
||||
("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff"), ("dot", "."), ("dots", ".."), ("double_encoded", "%256eibu")]
|
||||
MINDCASES = [
|
||||
mcase("list_a_percent_encoded_configured_mind_is_decoded_once", "list", "%6eibu"), mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"),
|
||||
mcase("read_a_fork_the_mind_holds", "read", "gamecult-chat", FID), mcase("answer_a_fork_the_mind_holds_is_stored_in_that_mind", "answer", "gamecult-chat", FID),
|
||||
mcase("answer_on_the_other_mind", "answer", "nibu", "nibu:question:glow-order"),
|
||||
] + [mcase(f"M1_{route}_{n}_is_unknown_and_opens_nothing", route, seg, FID if route != "list" else None) for route in ("list", "read", "answer") for n, seg in BAD_SEGMENTS] + [
|
||||
mcase("M2_read_unknown_mind_known_fork", "read", "ghost", FID), mcase("M2_read_known_mind_unknown_fork", "read", "gamecult-chat", "gamecult-chat:question:ghost"),
|
||||
mcase("M2_answer_unknown_mind_known_fork", "answer", "ghost", FID), mcase("M2_answer_known_mind_unknown_fork", "answer", "gamecult-chat", "gamecult-chat:question:ghost"),
|
||||
mcase("M2_list_unknown_mind", "list", "ghost"),
|
||||
mcase("M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "answer", "nibu", FID), mcase("M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", "answer", "gamecult-chat", "nibu:question:glow-order"),
|
||||
mcase("M4_desk_read_lists_every_mind", "list", "nibu", tok=DESK_READ), mcase("M4_desk_read_reads_every_mind", "read", "nibu", "nibu:question:glow-order", tok=DESK_READ),
|
||||
mcase("M4_desk_answer_cannot_list_any_mind", "list", "gamecult-chat", tok=DESK_ANSWER), mcase("M4_desk_answer_cannot_list_the_other_mind", "list", "nibu", tok=DESK_ANSWER),
|
||||
mcase("M4_desk_read_cannot_answer_on_any_mind", "answer", "nibu", "nibu:question:glow-order", tok=DESK_READ),
|
||||
mcase("auth_comes_before_the_mind_lookup_no_bearer", "list", "ghost", tok="none"), mcase("auth_comes_before_the_mind_lookup_scope", "answer", "ghost", FID, tok=DESK_READ),
|
||||
mcase("auth_comes_before_the_mind_lookup_non_active", "read", "ghost", FID, tok=TOK("bifrost-desk", "desk.read", active=False)),
|
||||
]
|
||||
SAME_RESPONSE = [["M2_read_unknown_mind_known_fork", "M2_read_known_mind_unknown_fork"], ["M2_answer_unknown_mind_known_fork", "M2_answer_known_mind_unknown_fork"],
|
||||
["M2_list_unknown_mind", "M2_read_known_mind_unknown_fork"], ["M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "M2_answer_known_mind_unknown_fork"]]
|
||||
def bad(name, why, m, **kw): return dict({"name": name, "why": why, "message": m}, **kw)
|
||||
fork = {
|
||||
"provenance": "Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Bytes spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. options_hash follows fork-card.md 'Options hash'; the vectors named heimdall_* are that file's, with its digests.",
|
||||
"optionsHash": hashes,
|
||||
"card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)},
|
||||
{"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0),
|
||||
"messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))},
|
||||
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))}],
|
||||
"invalid": [bad(n, w, m) for n, w, m in [
|
||||
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
|
||||
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])),
|
||||
("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]])),
|
||||
("status_unknown", "status is open or closed", card(status="provisional")),
|
||||
("options_hash_not_hex", "options_hash is 64 lowercase hex", dict(card(), options_hash="XYZ")),
|
||||
("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])),
|
||||
("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"])),
|
||||
("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)),
|
||||
]]},
|
||||
"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")),
|
||||
("note_4000_ascii_bytes", body(None, "x" * 4000)), ("note_astral_4000_bytes", body(None, "\U0001F600" * 1000)),
|
||||
("note_combining_4000_bytes", body(None, "e\u0301" * 1333 + "x"))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("answer_with_neither", "an answer object holds a choice, a note, or both", body(answer={"choice": None, "note": None})),
|
||||
("blank_note", "a blank note is no answer", body(None, " \u00a0 ")),
|
||||
("request_id_missing", "map-mode writes every key; a retry needs the id", {k: v for k, v in x1.items() if k != "request_id"}),
|
||||
("request_id_uppercase", "request_id is lowercase", dict(x1, request_id="0000000A-0000-4000-8000-00000000000B")),
|
||||
("request_id_not_a_uuid", "request_id is a lowercase D-format UUID", dict(x1, request_id="retry-1")),
|
||||
("note_over_4000", "notes are at most 4000 UTF-8 bytes", body(None, "x" * 4001)),
|
||||
("note_multibyte_4001_bytes", "a note is counted in UTF-8 bytes: 1999 e-acute is 3998 bytes, plus three ASCII is 4001 (rule: not expressible in the schema, 2002 characters)", body(None, "\u00e9" * 1999 + "abc")),
|
||||
("note_astral_4004_bytes", "1001 astral characters are 4004 bytes though only 1001 code points (rule: not expressible in the schema)", body(None, "\U0001F600" * 1001)),
|
||||
("note_combining_4002_bytes", "e plus a combining acute is 3 bytes: 1334 of them are 4002 (rule: not expressible in the schema)", body(None, "e\u0301" * 1334)),
|
||||
("options_hash_uppercase", "hash is lowercase hex", body("no", None, h=H.upper())),
|
||||
("answerer_key", "answerer is server-set; the door answers body_forbidden_field", dict(x1, answerer=OP)),
|
||||
("authority_key", "authority is server-set; the door answers body_forbidden_field", dict(x1, authority="Operator")),
|
||||
("answer_object_with_a_key", "the answer object is closed", body(answer={"choice": "no", "note": None, "weight": 1})),
|
||||
("answered_at_without_zone", "instants are UTC with a Z", body("no", None, at="2026-11-12T19:02:00")),
|
||||
("retracted_not_a_boolean", "retracted is a boolean", dict(x1, retracted="no")),
|
||||
("fork_id_empty", "fork_id names a fork", body("no", None, 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"))),
|
||||
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
|
||||
("refused_closed_retraction_keeps_no_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")),
|
||||
("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)),
|
||||
("refused_with_an_unknown_code", "the code set is closed", dict(REF, code="because")),
|
||||
("counted_naming_another_answer", "Counted names none", dict(CNT, of="n0")),
|
||||
("revision_naming_none", "a Revision names the counted answer", dict(REV, of=None)),
|
||||
("revision_that_also_retracts", "a variant carries only its own link (a retraction with no target, or a target with no retraction, cannot exist)", dict(REV, retracts="n1")),
|
||||
("retraction_naming_no_target", "a Retraction names the answer it retracts", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), retracts=None)),
|
||||
("refused_naming_a_target", "Refused carries only its code", dict(REF, retracts="n1")),
|
||||
("retraction_carrying_a_choice", "a Retraction carries no choice or note", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), choice="no")),
|
||||
("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="")),
|
||||
]]},
|
||||
"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],
|
||||
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
|
||||
{"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}],
|
||||
"rulingCites": cites,
|
||||
"transport": {
|
||||
"listRequest": {"valid": [{"name": "list_open_page", "message": lreq(status="open", limit=2), "messagePackHex": hx(lreq(status="open", limit=2))},
|
||||
{"name": "list_next_page", "message": lreq(status="open", cursor="c4", limit=2), "messagePackHex": hx(lreq(status="open", cursor="c4", limit=2))},
|
||||
{"name": "list_count_only", "message": lreq(limit=0), "messagePackHex": hx(lreq(limit=0))}],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("limit_51", "a page is at most 50 forks", lreq(limit=51)), ("limit_negative", "limit is 0 to 50", lreq(limit=-1)),
|
||||
("status_all", "status is open, recorded or nil", lreq(status="all")), ("cursor_malformed", "a cursor is c<N>", lreq(cursor="next")),
|
||||
("cursor_zero", "a cursor names a positive order", lreq(cursor="c0")), ("mind_empty", "a list names a Mind", lreq(mind="")),
|
||||
("unknown_key", "closed document", dict(lreq(), sort="oldest"))]]},
|
||||
"readRequest": {"valid": [{"name": "read_one", "message": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID}, "messagePackHex": hx({"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID})}],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("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])]],
|
||||
"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))),
|
||||
("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))]],
|
||||
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
|
||||
"mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE},
|
||||
"authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH],
|
||||
},
|
||||
"scenarios": [{"name": n, "card": c, "optionsHash": c["options_hash"], "steps": steps, "record": r, "recordHex": hx(r)} for n, c, steps, r in scen],
|
||||
}
|
||||
(OUT / "fork-card-vectors.json").write_text(json.dumps(fork, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print("ok", len(vote["valid"]), len(vote["invalid"]), len(fork["scenarios"]))
|
||||
647
tests/GameCult.Networking.Tests/GovernanceVectorTests.cs
Normal file
647
tests/GameCult.Networking.Tests/GovernanceVectorTests.cs
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
|
||||
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with MessagePack C#, must reproduce the bytes, judges
|
||||
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
|
||||
public sealed class GovernanceVectorTests
|
||||
{
|
||||
private const string VoteSchema = "governance/vote-api.schema.json";
|
||||
private const string ForkSchema = "governance/fork-card.schema.json";
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
|
||||
}
|
||||
|
||||
private static JsonNode Load(string name) =>
|
||||
JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", "governance", name)))!;
|
||||
|
||||
private static readonly JsonNode Vote = Load("vote-api-vectors.json");
|
||||
private static readonly JsonNode Fork = Load("fork-card-vectors.json");
|
||||
private static MiniJsonSchemaValidator Validator() => new(Path.Combine(RepoRoot(), "contracts", "cultnet"));
|
||||
|
||||
private static List<string> Errors(string file, string def, JsonNode? value)
|
||||
{
|
||||
var element = JsonSerializer.SerializeToElement(value);
|
||||
return Validator().Validate(element, $"{file}#/$defs/{def}");
|
||||
}
|
||||
|
||||
// MessagePack C# writes the message in the order the keys appear: shortest ints, shortest strings.
|
||||
private static void Write(ref MessagePackWriter w, JsonNode? n)
|
||||
{
|
||||
switch (n)
|
||||
{
|
||||
case null: w.WriteNil(); break;
|
||||
case JsonObject o:
|
||||
w.WriteMapHeader(o.Count);
|
||||
foreach (var kv in o) { w.Write(kv.Key); Write(ref w, kv.Value); }
|
||||
break;
|
||||
case JsonArray a:
|
||||
w.WriteArrayHeader(a.Count);
|
||||
foreach (var item in a) Write(ref w, item);
|
||||
break;
|
||||
case JsonValue v when v.TryGetValue<bool>(out var b): w.Write(b); break;
|
||||
case JsonValue v when v.TryGetValue<int>(out var i): w.Write(i); break;
|
||||
case JsonValue v when v.TryGetValue<long>(out var l): w.Write(l); break;
|
||||
case JsonValue v: w.Write(v.GetValue<string>()); break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string HexOf(JsonNode? message)
|
||||
{
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
Write(ref writer, message);
|
||||
writer.Flush();
|
||||
return Convert.ToHexString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
private static string S(JsonNode? n) => n!.GetValue<string>();
|
||||
private static JsonNode? Clone(JsonNode? n) => n is null ? null : JsonNode.Parse(n.ToJsonString());
|
||||
|
||||
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
|
||||
private static IEnumerable<string> VoteRuleErrors(string def, JsonNode? message)
|
||||
{
|
||||
if (def == "cast" && message?["ballot"] is JsonNode ballot && S(ballot["reason"]).Length > 280) yield return "reason is over 280 UTF-16 code units";
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VoteApi_ValidDocuments_ValidateEncodeToTheVectorBytesAndDecodeBack()
|
||||
{
|
||||
foreach (var v in Vote["valid"]!.AsArray())
|
||||
{
|
||||
var name = S(v!["name"]);
|
||||
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]), Is.Empty, name);
|
||||
Assert.That(VoteRuleErrors(S(v["def"]), v["message"]), Is.Empty, name);
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
|
||||
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
|
||||
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VoteApi_EveryNegative_IsRefusedBySchemaAndKeepsItsBytes()
|
||||
{
|
||||
foreach (var v in Vote["invalid"]!.AsArray())
|
||||
{
|
||||
var name = S(v!["name"]);
|
||||
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]).Count > 0 || VoteRuleErrors(S(v["def"]), v["message"]).Any(), Is.True, $"{name}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CommitmentBytes(JsonNode b, string voter) => Encoding.UTF8.GetBytes(
|
||||
$"gamecult.sealed_ballot.v0\n{S(b["motionId"])}\n{voter}\n{S(b["persona"])}\n{b["round"]!.GetValue<long>()}\n{S(b["candidate"])}\n{S(b["reason"])}\n{S(b["salt"])}");
|
||||
|
||||
[Test]
|
||||
public void VoteApi_Commitment_IsSha256OfTheCanonicalBytes_AndRevealMatchesExactlyWhenItHashesToIt()
|
||||
{
|
||||
foreach (var c in Vote["commitment"]!.AsArray())
|
||||
{
|
||||
var bytes = CommitmentBytes(c!["ballot"]!, S(c["voterUserAccountId"]));
|
||||
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(c["canonicalHex"])), S(c["name"]));
|
||||
Assert.That(Sha(bytes), Is.EqualTo(S(c["sha256"])), S(c["name"]));
|
||||
}
|
||||
foreach (var p in Vote["pairs"]!.AsArray())
|
||||
{
|
||||
var digest = Sha(CommitmentBytes(p!["reveal"]!["ballot"]!, S(p["voterUserAccountId"])));
|
||||
Assert.That(digest == S(p["commit"]!["commitment"]), Is.EqualTo(p["matches"]!.GetValue<bool>()), S(p["name"]));
|
||||
Assert.That(Errors(VoteSchema, "cast", p["commit"]), Is.Empty);
|
||||
Assert.That(Errors(VoteSchema, "cast", p["reveal"]), Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VoteApi_EachRefusalCode_HasAVectorAStatusAndTheVectorBytes()
|
||||
{
|
||||
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", VoteSchema)))!;
|
||||
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
|
||||
var vectors = Vote["refusals"]!.AsArray();
|
||||
Assert.That(vectors.Select(r => S(r!["message"]!["code"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(codes));
|
||||
foreach (var r in vectors)
|
||||
{
|
||||
Assert.That(Errors(VoteSchema, "refusal", r!["message"]), Is.Empty, S(r["name"]));
|
||||
Assert.That(HexOf(r["message"]), Is.EqualTo(S(r["messagePackHex"])), S(r["name"]));
|
||||
Assert.That(r["httpStatus"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(400));
|
||||
}
|
||||
Assert.That(vectors.Single(r => S(r!["name"]) == "rate_limited")!["message"]!["retryAfterSeconds"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(1));
|
||||
}
|
||||
|
||||
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
|
||||
private static string Jstr(string s)
|
||||
{
|
||||
var sb = new StringBuilder("\"");
|
||||
foreach (var rune in s.EnumerateRunes())
|
||||
{
|
||||
var o = rune.Value;
|
||||
sb.Append(o switch
|
||||
{
|
||||
'"' => "\\\"", '\\' => "\\\\", 8 => "\\b", 9 => "\\t", 10 => "\\n", 12 => "\\f", 13 => "\\r",
|
||||
< 0x20 => "\\u" + o.ToString("x4"),
|
||||
_ => rune.ToString()
|
||||
});
|
||||
}
|
||||
return sb.Append('"').ToString();
|
||||
}
|
||||
|
||||
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
|
||||
private static string N(string s) => s.Normalize(NormalizationForm.FormC);
|
||||
private static byte[] Canonical(JsonNode c)
|
||||
{
|
||||
var options = string.Join(",", c["options"]!.AsArray().Select(o => $"{{\"id\":{Jstr(N(S(o!["id"])))},\"text\":{Jstr(N(S(o["text"])))}}}"));
|
||||
var depends = string.Join(",", c["depends"]!.AsArray().Select(d => Jstr(N(S(d)))));
|
||||
return Encoding.UTF8.GetBytes($"{{\"context\":{Jstr(N(S(c["context"])))},\"depends\":[{depends}],\"options\":[{options}],\"recommendation\":{Jstr(N(S(c["recommendation"])))},\"title\":{Jstr(N(S(c["title"])))}}}");
|
||||
}
|
||||
private static string HashOf(JsonNode c) => Sha(Canonical(c));
|
||||
|
||||
private static IEnumerable<string> CardRuleErrors(JsonNode c)
|
||||
{
|
||||
var ids = c["options"]!.AsArray().Select(o => S(o!["id"])).ToList();
|
||||
if (ids.Distinct().Count() != ids.Count) yield return "duplicate option ids";
|
||||
if (!ids.Contains(S(c["recommendation"]))) yield return "recommendation is not an option";
|
||||
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 (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";
|
||||
if (S(c["options_hash"]) != HashOf(c)) yield return "options_hash is not the hash of the shown members";
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_OptionsHash_IsSha256OfTheCanonicalJson_ComputedHere_AndEveryShownMemberMovesIt()
|
||||
{
|
||||
foreach (var h in Fork["optionsHash"]!.AsArray())
|
||||
{
|
||||
var bytes = Canonical(h!);
|
||||
Assert.That(Encoding.UTF8.GetString(bytes), Is.EqualTo(S(h["canonical"])), S(h["name"]));
|
||||
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(h["canonicalHex"])), S(h["name"]));
|
||||
Assert.That(Sha(bytes), Is.EqualTo(S(h["sha256"])), S(h["name"]));
|
||||
}
|
||||
var byName = Fork["optionsHash"]!.AsArray().ToDictionary(h => S(h!["name"]), h => S(h!["sha256"]));
|
||||
var baseline = byName["heimdall_A_five_members"];
|
||||
Assert.That(byName["hidden_members_do_not_move_it"], Is.EqualTo(baseline));
|
||||
Assert.That(byName["heimdall_E_decomposed_equals_D"], Is.EqualTo(byName["heimdall_D_nfc_escapes_astral"]), "an NFD and an NFC spelling hash alike");
|
||||
foreach (var moved in new[] { "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" })
|
||||
Assert.That(byName[moved], Is.Not.EqualTo(baseline), moved);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_MutatingAnyShownMember_AnOptionIdIncluded_ChangesTheHash()
|
||||
{
|
||||
var card = Fork["card"]!["valid"]![0]!["message"]!;
|
||||
var baseline = HashOf(card);
|
||||
var mutations = new Action<JsonNode>[]
|
||||
{
|
||||
c => c["title"] = S(c["title"]) + "x", c => c["context"] = S(c["context"]) + "x",
|
||||
c => c["options"]![0]!["id"] = S(c["options"]![0]!["id"]) + "x", c => c["options"]![0]!["text"] = S(c["options"]![0]!["text"]) + "x",
|
||||
c => { var o = c["options"]!.AsArray(); var first = o[0]!; o.RemoveAt(0); o.Add(first); },
|
||||
c => c["recommendation"] = S(c["options"]![0]!["id"]) == S(c["recommendation"]) ? S(c["options"]![1]!["id"]) : S(c["options"]![0]!["id"]),
|
||||
c => c["depends"]!.AsArray().Add("a:b:c"), c => c["depends"]!.AsArray().Clear(),
|
||||
};
|
||||
for (var i = 0; i < mutations.Length; i++)
|
||||
{
|
||||
var copy = Clone(card);
|
||||
mutations[i](copy);
|
||||
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");
|
||||
Assert.That(HashOf(hidden), Is.EqualTo(baseline), "fork_id and the unshown members stay outside the hash");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_DocumentsValidateAndEncodeToTheVectorBytes()
|
||||
{
|
||||
foreach (var v in Fork["card"]!["valid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "card", v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(CardRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
|
||||
foreach (var v in Fork[section]!["valid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(NoteRuleErrors(def, v["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
|
||||
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, S(v["name"]));
|
||||
}
|
||||
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
|
||||
IEnumerable<string> codesOfRefusal() => schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S);
|
||||
IEnumerable<string> Codes(string def) => schema["$defs"]![def]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
|
||||
Assert.That(Fork["results"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("deskAnswerRefused")));
|
||||
foreach (var v in Fork["results"]!.AsArray())
|
||||
{
|
||||
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(Fork["doorRefusals"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("refusal")));
|
||||
foreach (var v in Fork["unavailable"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "unavailable", v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
Assert.That(v["httpStatus"]!.GetValue<int>(), Is.EqualTo(503));
|
||||
Assert.That(codesOfRefusal(), Does.Not.Contain("unavailable"), "unavailable is kept apart from the refusal set");
|
||||
}
|
||||
foreach (var v in Fork["doorRefusals"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_EveryNegative_IsRefusedBySchemaOrAStatedRule()
|
||||
{
|
||||
foreach (var v in Fork["card"]!["invalid"]!.AsArray())
|
||||
Assert.That(Errors(ForkSchema, "card", v!["message"]).Count > 0 || CardRuleErrors(v["message"]!).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
|
||||
foreach (var v in Fork[section]!["invalid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || NoteRuleErrors(def, v["message"]).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var note = def switch { "answerRequest" => message?["answer"]?["note"], "deskAnswer" => message?["note"], _ => null };
|
||||
if (note is not null && Encoding.UTF8.GetByteCount(S(note)) > 4000) yield return "note is over 4000 UTF-8 bytes";
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> Tails = new()
|
||||
{
|
||||
["unauthenticated"] = "a verified session is required",
|
||||
["body_forbidden_field"] = "answerer and authority are set by the receiver",
|
||||
["body_unknown_field"] = "the body is a closed shape",
|
||||
["body_invalid"] = "the body does not satisfy gamecult.fork.answer_request.v0",
|
||||
["fork_unknown"] = "no such fork",
|
||||
["forbidden"] = "only Active members may use the desk",
|
||||
["request_id_conflict"] = "this request_id was used for a different body",
|
||||
};
|
||||
private static readonly string[] RequestKeys = { "schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id" };
|
||||
|
||||
private static bool HasKey(JsonNode? n, params string[] names) => n switch
|
||||
{
|
||||
JsonObject o => o.Any(kv => names.Contains(kv.Key) || HasKey(kv.Value, names)),
|
||||
JsonArray a => a.Any(x => HasKey(x, names)),
|
||||
_ => false
|
||||
};
|
||||
|
||||
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
|
||||
private static (List<JsonNode?> Responses, JsonNode Record) Judge(JsonNode startCard, JsonArray steps)
|
||||
{
|
||||
var card = startCard;
|
||||
var answers = new JsonArray();
|
||||
var responses = new List<JsonNode?>();
|
||||
var seen = new Dictionary<string, (JsonNode Body, JsonNode Response)>(); // (answerer, request_id) -> the first stored outcome
|
||||
string? Counted()
|
||||
{
|
||||
var hash = HashOf(card);
|
||||
foreach (var a in answers)
|
||||
{
|
||||
if (S(a!["outcome"]) is not ("Counted" or "Revision") || S(a["options_hash"]) != hash) continue;
|
||||
if (answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))) continue;
|
||||
return S(a["answer_id"]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
foreach (var step in steps)
|
||||
{
|
||||
var kind = S(step!["kind"]);
|
||||
if (kind == "matrix_event") { responses.Add(null); continue; } // rule 9: Matrix never changes a decision
|
||||
if (kind == "card") { card = step["card"]!; responses.Add(null); continue; }
|
||||
var s = step["server"]!;
|
||||
var b = step["body"]!.AsObject();
|
||||
JsonNode Door(string code) => new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" };
|
||||
JsonNode Store(string outcome, string? link, string? code)
|
||||
{
|
||||
var a = b["answer"] as JsonObject;
|
||||
var doc = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "gamecult.desk_answer.v0", ["answer_id"] = S(s["answer_id"]), ["fork_id"] = Clone(b["fork_id"]), ["outcome"] = outcome,
|
||||
["of"] = outcome == "Revision" ? link : null, ["retracts"] = outcome == "Retraction" ? link : null, ["code"] = outcome == "Refused" ? code : null,
|
||||
["options_hash"] = Clone(b["options_hash"]), ["choice"] = Clone(a?["choice"]), ["note"] = Clone(a?["note"]), ["answered_at"] = Clone(b["answered_at"]),
|
||||
["received_at"] = Clone(s["received_at"]),
|
||||
["claim_in_future"] = (DateTimeOffset.Parse(S(b["answered_at"])) - DateTimeOffset.Parse(S(s["received_at"]))).TotalSeconds > 300,
|
||||
["answerer"] = Clone(s["answerer"]), ["authority"] = Clone(s["authority"]), ["request_id"] = Clone(b["request_id"])
|
||||
};
|
||||
answers.Add(Clone(doc));
|
||||
var stored = new JsonObject { ["schemaVersion"] = "gamecult.fork.answer_result.v0", ["answer"] = doc, ["card"] = code == "stale_fork" ? Clone(card) : null };
|
||||
seen[S(s["answerer"]) + "|" + S(b["request_id"])] = (Clone(b)!, Clone(stored)!);
|
||||
return stored;
|
||||
}
|
||||
JsonNode Refuse(string code) => Store("Refused", null, code);
|
||||
var deciders = card["deciders"]!.AsArray().Select(S).ToList();
|
||||
var retracted = b["retracted"] is JsonValue rv && rv.TryGetValue<bool>(out var rb) && rb;
|
||||
var down = s["door_down"] is not null; // the second door failed to answer; "stored" says whether Huginn had the request by then
|
||||
var reached = s["stored"] is not null;
|
||||
JsonNode Unavailable() => new JsonObject { ["schemaVersion"] = "gamecult.fork.unavailable.v0", ["code"] = "unavailable", ["error"] = "unavailable: the outcome is not confirmed, try again with the same request_id", ["retryAfterSeconds"] = Clone(s["retry_after_seconds"]) };
|
||||
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
||||
JsonNode Replay(JsonNode stored) { var copy = Clone(stored)!; if (S(copy["answer"]!["code"] ?? "") == "stale_fork") copy["card"] = Clone(card); return copy; }
|
||||
JsonNode response;
|
||||
if (s["answerer"] is null) response = Door("unauthenticated");
|
||||
else if (s["active"] is JsonValue activeValue && !activeValue.GetValue<bool>()) response = Door("forbidden");
|
||||
else if (HasKey(b, "answerer", "authority")) response = Door("body_forbidden_field");
|
||||
else if (b.Any(kv => !RequestKeys.Contains(kv.Key)) || (b["answer"] is JsonObject ao && ao.Any(kv => kv.Key is not ("choice" or "note")))) response = Door("body_unknown_field");
|
||||
else if (Errors(ForkSchema, "answerRequest", b).Count > 0 || NoteRuleErrors("answerRequest", b).Any() || (!retracted && b["answer"] is null)) response = Door("body_invalid");
|
||||
else if (down && !reached) response = Unavailable();
|
||||
else if (seen.TryGetValue(S(s["answerer"]) + "|" + S(b["request_id"]), out var prior)) response = JsonNode.DeepEquals(prior.Body, b) ? Replay(prior.Response) : Door("request_id_conflict");
|
||||
else if (S(b["fork_id"]) != S(card["fork_id"])) response = Door("fork_unknown");
|
||||
else if (S(b["options_hash"]) != HashOf(card)) response = Refuse("stale_fork");
|
||||
else if (!deciders.Contains(S(s["answerer"]))) response = Refuse("not_a_decider");
|
||||
else if (S(card["status"]) == "closed") response = Refuse("fork_closed");
|
||||
else if (retracted || b["retracts"] is not null)
|
||||
{
|
||||
var target = answers.FirstOrDefault(a => a is not null && b["retracts"] is not null && S(a["answer_id"]) == S(b["retracts"])
|
||||
&& S(a["outcome"]) is "Counted" or "Revision" && S(a["options_hash"]) == S(b["options_hash"]));
|
||||
if (!retracted || b["answer"] is not null || b["retracts"] is null || target is null) response = Refuse("bad_retraction");
|
||||
else if (answers.Any(a => S(a!["outcome"]) == "Retraction" && S(a["retracts"]) == S(target["answer_id"]))) response = Refuse("already_retracted");
|
||||
else response = Store("Retraction", S(target["answer_id"]), null);
|
||||
}
|
||||
else if (b["answer"]!["choice"] is not null && !card["options"]!.AsArray().Any(o => S(o!["id"]) == S(b["answer"]!["choice"]))) response = Refuse("choice_invalid");
|
||||
else
|
||||
{
|
||||
var first = Counted();
|
||||
response = first is null ? Store("Counted", null, null) : Store("Revision", first, null);
|
||||
}
|
||||
responses.Add(down && reached ? Unavailable() : response);
|
||||
}
|
||||
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
|
||||
};
|
||||
return (responses, record);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_ARequestIdBelongsToItsAccount_AnotherAccountsIdenticalBodyGetsItsOwnAnswerNeverTheFirstOutcome()
|
||||
{
|
||||
var crossed = 0;
|
||||
foreach (var sc in Fork["scenarios"]!.AsArray())
|
||||
{
|
||||
var first = new Dictionary<string, (string Answerer, JsonNode Result)>(); // request_id -> the account that first got a stored result and what it got
|
||||
foreach (var step in sc!["steps"]!.AsArray())
|
||||
{
|
||||
if (S(step!["kind"]) != "answer" || step["response"]?["answer"] is null) continue;
|
||||
var who = S(step["server"]!["answerer"]);
|
||||
var result = step["response"]!;
|
||||
Assert.That(S(result["answer"]!["answerer"]), Is.EqualTo(who), $"{S(sc["name"])}: a result is stamped with the account that asked");
|
||||
var rid = S(step["body"]!["request_id"]);
|
||||
if (!first.TryGetValue(rid, out var prior)) { first[rid] = (who, result); continue; }
|
||||
if (prior.Answerer == who) continue;
|
||||
crossed++;
|
||||
Assert.That(S(result["answer"]!["answer_id"]), Is.Not.EqualTo(S(prior.Result["answer"]!["answer_id"])), $"{S(sc["name"])}: its own answer id");
|
||||
Assert.That(JsonNode.DeepEquals(result, prior.Result), Is.False, $"{S(sc["name"])}: the first account's outcome is never returned");
|
||||
}
|
||||
}
|
||||
Assert.That(crossed, Is.GreaterThanOrEqualTo(3), "the vectors carry the cross-account cases");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_EveryScenario_ReplaysToTheVectorsResponsesAndRecord_BytesIncluded()
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
var doorRefusals = 0;
|
||||
var door = new HashSet<string> { "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict" };
|
||||
foreach (var sc in Fork["scenarios"]!.AsArray())
|
||||
{
|
||||
var name = S(sc!["name"]);
|
||||
Assert.That(HashOf(sc["card"]!), Is.EqualTo(S(sc["optionsHash"])), name);
|
||||
Assert.That(CardRuleErrors(sc["card"]!), Is.Empty, name);
|
||||
var steps = sc["steps"]!.AsArray();
|
||||
var (responses, record) = Judge(sc["card"]!, steps);
|
||||
Assert.That(responses.Count, Is.EqualTo(steps.Count), name);
|
||||
for (var i = 0; i < steps.Count; i++)
|
||||
Assert.That(JsonNode.DeepEquals(responses[i], steps[i]!["response"]), Is.True, $"{name} step {i}: {responses[i]?.ToJsonString()}");
|
||||
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 step in steps)
|
||||
{
|
||||
var r = step!["response"];
|
||||
if (r is null) continue;
|
||||
Assert.That(Errors(ForkSchema, "answerResult", r).Count == 0 || Errors(ForkSchema, "refusal", r).Count == 0 || Errors(ForkSchema, "unavailable", r).Count == 0, Is.True, name);
|
||||
if (r["code"] is not null)
|
||||
{
|
||||
seen.Add(S(r["code"]));
|
||||
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
|
||||
if (S(r["code"]) == "unavailable")
|
||||
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.EqualTo(step["server"]!["stored"] is not null), $"{name} unavailable");
|
||||
if (door.Contains(S(r["code"])))
|
||||
{
|
||||
doorRefusals++;
|
||||
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.False, $"{name} stored a door refusal");
|
||||
}
|
||||
}
|
||||
else seen.Add(S(r["answer"]!["code"] ?? r["answer"]!["outcome"]));
|
||||
}
|
||||
foreach (var a in sc["record"]!["answers"]!.AsArray()) seen.Add(S(a!["outcome"]));
|
||||
}
|
||||
Assert.That(doorRefusals, Is.GreaterThanOrEqualTo(8));
|
||||
foreach (var want in new[] { "Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork",
|
||||
"not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted" })
|
||||
Assert.That(seen, Does.Contain(want));
|
||||
}
|
||||
|
||||
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
|
||||
private static bool AdmitRuling(JsonNode cite, JsonArray answers)
|
||||
{
|
||||
if (S(cite["authority"]) != "Operator") return true;
|
||||
var cited = answers.FirstOrDefault(a => cite["answer_id"] is not null && S(a!["answer_id"]) == S(cite["answer_id"]));
|
||||
return cited is not null && S(cited["outcome"]) == "Counted" && S(cited["authority"]) == "Operator";
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_OperatorAuthority_NeedsACitedCountedAnswerRecord()
|
||||
{
|
||||
foreach (var c in Fork["rulingCites"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "rulingCite", c!["message"]), Is.Empty, S(c["name"]));
|
||||
Assert.That(HexOf(c["message"]), Is.EqualTo(S(c["messagePackHex"])), S(c["name"]));
|
||||
Assert.That(AdmitRuling(c["message"]!, c["answers"]!.AsArray()), Is.EqualTo(c["admit"]!.GetValue<bool>()), S(c["name"]));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transport: list, read, auth (fork-card.md "Transport") ----
|
||||
private static IEnumerable<string> EntryRuleErrors(JsonNode e)
|
||||
{
|
||||
var card = e["card"]!; var record = e["record"]!;
|
||||
foreach (var error in CardRuleErrors(card)) yield return error;
|
||||
if (S(record["fork_id"]) != S(card["fork_id"])) yield return "record of another fork";
|
||||
if (S(record["options_hash"]) != S(card["options_hash"])) yield return "record under another hash";
|
||||
var answers = record["answers"]!.AsArray();
|
||||
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";
|
||||
}
|
||||
|
||||
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
||||
private static JsonNode JudgeList(JsonArray entries, long ordinal, JsonNode request)
|
||||
{
|
||||
long Order(JsonNode? e) => e!["card"]!["order"]!.GetValue<long>();
|
||||
var rows = entries.OrderByDescending(Order).ToList();
|
||||
var want = rows.Where(e => request["status"] is null || (S(e!["card"]!["status"]) == "open") == (S(request["status"]) == "open")).ToList();
|
||||
if (request["cursor"] is not null) { var below = long.Parse(S(request["cursor"])[1..]); want = want.Where(e => Order(e) < below).ToList(); }
|
||||
var page = want.Take(request["limit"]!.GetValue<int>()).ToList();
|
||||
var more = want.Count > page.Count && page.Count > 0;
|
||||
return new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "gamecult.fork.list.v0", ["mind"] = Clone(request["mind"]), ["stamp"] = $"s{ordinal}",
|
||||
["open_count"] = rows.Count(e => S(e!["card"]!["status"]) == "open"), ["recorded_count"] = rows.Count(e => S(e!["card"]!["status"]) == "closed"),
|
||||
["forks"] = new JsonArray(page.Select(Clone).ToArray()), ["next_cursor"] = more ? JsonValue.Create($"c{Order(page[^1])}") : null
|
||||
};
|
||||
}
|
||||
|
||||
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
|
||||
private static string AuthJudge(string route, JsonNode? token, bool huginnDown)
|
||||
{
|
||||
if (token is null || S(token["audience"]) != "bifrost-desk") return "unauthenticated";
|
||||
var need = route == "answer" ? "desk.answer" : "desk.read";
|
||||
if (!token["scopes"]!.AsArray().Any(x => S(x) == need)) return "insufficient_scope";
|
||||
if (!token["active"]!.GetValue<bool>()) return "forbidden";
|
||||
return huginnDown ? "unavailable" : "ok";
|
||||
}
|
||||
|
||||
private static string PercentDecode(string s)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
for (var i = 0; i < s.Length;)
|
||||
{
|
||||
if (s[i] == '%' && i + 2 < s.Length && Uri.IsHexDigit(s[i + 1]) && Uri.IsHexDigit(s[i + 2])) { bytes.Add(Convert.ToByte(s.Substring(i + 1, 2), 16)); i += 3; continue; }
|
||||
var rune = System.Text.Rune.GetRuneAt(s, i);
|
||||
bytes.AddRange(Encoding.UTF8.GetBytes(rune.ToString())); i += rune.Utf16SequenceLength;
|
||||
}
|
||||
return new UTF8Encoding(false, true).GetString(bytes.ToArray()); // throws DecoderFallbackException (an ArgumentException) on invalid UTF-8
|
||||
}
|
||||
|
||||
// The {mind} segment is decoded once and compared, ordinally, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
|
||||
private static (string Code, string? Opened, string? StoredIn) MindRoute(JsonNode config, string route, string segment, string? fork, JsonNode? token)
|
||||
{
|
||||
var auth = AuthJudge(route, token, false);
|
||||
if (auth != "ok") return (auth, null, null);
|
||||
string mind;
|
||||
try { mind = PercentDecode(segment); } catch (ArgumentException) { return ("fork_unknown", null, null); } // bytes that are not UTF-8 are a miss
|
||||
var minds = config["minds"]!.AsObject();
|
||||
if (!minds.TryGetPropertyValue(mind, out var socket)) return ("fork_unknown", null, null);
|
||||
if (route != "list" && !config["forks"]![mind]!.AsArray().Any(f => S(f) == fork)) return ("fork_unknown", S(socket), null);
|
||||
return ("ok", S(socket), route == "answer" ? mind : null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_MindSegment_IsOnlyComparedWithTheConfiguredList_AnUnknownMindIsTheUnknownForkRefusal()
|
||||
{
|
||||
var config = Fork["transport"]!["mindRoutes"]!;
|
||||
var refusals = Fork["doorRefusals"]!.AsArray().ToDictionary(r => S(r!["name"]), r => r!);
|
||||
var byName = new Dictionary<string, (string Code, string? Opened, string? StoredIn)>();
|
||||
foreach (var c in config["cases"]!.AsArray())
|
||||
{
|
||||
var got = MindRoute(config, S(c!["route"]), S(c["mind"]), c["fork_id"] is null ? null : S(c["fork_id"]), c["token"]);
|
||||
byName[S(c["name"])] = got;
|
||||
Assert.That(got.Code, Is.EqualTo(S(c["expect"])), S(c["name"]));
|
||||
Assert.That(got.Opened, Is.EqualTo(c["opened"] is null ? null : S(c["opened"])), $"{S(c["name"])}: the socket opened");
|
||||
Assert.That(got.StoredIn, Is.EqualTo(c["stored_in"] is null ? null : S(c["stored_in"])), $"{S(c["name"])}: where an answer was stored");
|
||||
Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(got.Code == "ok" ? 200 : refusals[got.Code]["httpStatus"]!.GetValue<int>()), S(c["name"]));
|
||||
if (got.Code == "fork_unknown")
|
||||
{
|
||||
var error = S(refusals["fork_unknown"]["message"]!["error"]);
|
||||
Assert.That(error.Contains(S(c["mind"])), Is.False, $"{S(c["name"])}: the refusal does not name the Mind");
|
||||
Assert.That(c["fork_id"] is not null && error.Contains(S(c["fork_id"])), Is.False, $"{S(c["name"])}: the refusal does not name the fork");
|
||||
}
|
||||
}
|
||||
foreach (var pair in config["sameResponse"]!.AsArray())
|
||||
{
|
||||
var a = byName[S(pair![0])]; var b = byName[S(pair[1])];
|
||||
Assert.That(a.Code, Is.EqualTo(b.Code));
|
||||
string Built(string code) => HexOf(new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" });
|
||||
Assert.That(Built(a.Code), Is.EqualTo(Built(b.Code)).And.EqualTo(S(refusals[a.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes, built from the spec's rule");
|
||||
}
|
||||
Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(39), "every bad segment opens nothing");
|
||||
Assert.That(byName.Values.Where(v => v.StoredIn is not null).Select(v => v.StoredIn).Distinct().Count(), Is.EqualTo(2), "an answer is stored in the route's Mind only, and both Minds take one");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_RequestsAndEntriesValidateEncodeToTheVectorBytes_AndEveryNegativeIsRefused()
|
||||
{
|
||||
var t = Fork["transport"]!;
|
||||
foreach (var (section, def) in new[] { ("listRequest", "listRequest"), ("readRequest", "readRequest"), ("entry", "entry") })
|
||||
{
|
||||
foreach (var v in t[section]!["valid"]!.AsArray())
|
||||
{
|
||||
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"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
foreach (var v in t[section]!["invalid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || (def == "entry" && EntryRuleErrors(v["message"]!).Any()), Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
}
|
||||
foreach (var v in t["listInvalid"]!.AsArray())
|
||||
{
|
||||
Assert.That(Errors(ForkSchema, "list", v!["message"]), Is.Not.Empty, $"{S(v["name"])}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_EveryListRequest_ReplaysToTheVectorsResponse_NewestFirst_PagedByTheCursor()
|
||||
{
|
||||
foreach (var sc in Fork["transport"]!["listScenarios"]!.AsArray())
|
||||
{
|
||||
var name = S(sc!["name"]);
|
||||
var entries = sc["entries"]!.AsArray();
|
||||
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
|
||||
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(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);
|
||||
if (previous is not null) Assert.That(S(step["request"]!["cursor"]), Is.EqualTo(S(previous["next_cursor"])), $"{name}: the second request follows the first page's cursor");
|
||||
previous = response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing()
|
||||
{
|
||||
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
|
||||
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).ToList();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var c in Fork["transport"]!["authCases"]!.AsArray())
|
||||
{
|
||||
var got = AuthJudge(S(c!["route"]), c["token"], c["huginn_down"]!.GetValue<bool>());
|
||||
Assert.That(got, Is.EqualTo(S(c["expect"])), S(c["name"]));
|
||||
if (got is not ("ok" or "unavailable")) Assert.That(codes, Does.Contain(got), S(c["name"]));
|
||||
seen.Add(S(c["route"]) + ":" + got);
|
||||
}
|
||||
Assert.That(seen, Does.Contain("list:insufficient_scope").And.Contain("answer:insufficient_scope").And.Contain("read:forbidden").And.Contain("list:unavailable").And.Contain("read:unavailable").And.Contain("answer:unavailable").And.Contain("read:unauthenticated").And.Contain("answer:ok"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,9 +180,10 @@ namespace GameCult.Networking.Tests
|
|||
if (instance.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = instance.GetString()!;
|
||||
if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32())
|
||||
var length = value.EnumerateRunes().Count(); // JSON Schema counts characters as code points, as Ajv does, not UTF-16 units
|
||||
if (schema.TryGetProperty("minLength", out var minLength) && length < minLength.GetInt32())
|
||||
errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}");
|
||||
if (schema.TryGetProperty("maxLength", out var maxLength) && value.Length > maxLength.GetInt32())
|
||||
if (schema.TryGetProperty("maxLength", out var maxLength) && length > maxLength.GetInt32())
|
||||
errors.Add($"{path}: string longer than maxLength {maxLength.GetInt32()}");
|
||||
if (schema.TryGetProperty("pattern", out var pattern))
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue