Fork card: order is a unique rank per Mind; duplicate-order and order-0 vectors #86

Merged
libby merged 2 commits from libby/fork-card-order-unique into main 2026-11-11 00:33:36 +00:00
5 changed files with 818 additions and 16 deletions

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@ All documents are CultNet map-mode: every key written, nil where absent, closed
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `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.
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
### Options hash (canonical bytes)
@ -120,13 +120,13 @@ Calls 1 and 2 are enough for a read-only desk. Every response is the pinned Mess
**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`).
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
**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.
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; 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, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (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.
@ -147,7 +147,7 @@ The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a
| `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` |
| `order` | none (unique rank per Mind, from 0) | `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` |
@ -188,7 +188,7 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
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.
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 `n<seq>` answer ordinal alone decided answer 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_*`).

View file

@ -387,6 +387,17 @@ const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_has
return errors;
};
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
const orderErrors = (cards: { fork_id: string; order: number }[]): string[] => {
const holder = new Map<number, string>();
const errors: string[] = [];
for (const c of cards) {
if (holder.has(c.order)) errors.push(`order ${c.order} is shared by ${holder.get(c.order)} and ${c.fork_id}`);
else holder.set(c.order, c.fork_id);
}
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);
@ -461,7 +472,9 @@ test("fork transport: requests and entries validate and encode to the vector byt
}
}
for (const v of t.listInvalid) {
assert.equal(check(forkSchema, "list", v.message), false, `${v.name}: ${v.why}`);
const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0;
assert.ok(refused, `${v.name}: ${v.why}`);
if (v.refusal !== undefined) assert.deepEqual(orderErrors(v.message.forks.map((f: any) => f.card)), [v.refusal], v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
});
@ -469,6 +482,7 @@ test("fork transport: requests and entries validate and encode to the vector byt
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);
assert.deepEqual(orderErrors(sc.entries.map((e: any) => e.card)), [], 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);
@ -483,6 +497,13 @@ test("fork transport: every list request replays to the vector's response, newes
}
});
test("fork transport: order 0 is legal, no cursor names it, and the page ending at order 1 hands on to it", () => {
const sc = fork.transport.listScenarios.find((x: any) => x.name === "order_zero_is_legal_but_no_cursor_names_it");
assert.deepEqual(sc.steps.map((s: any) => s.response.next_cursor), ["c1", null]);
assert.deepEqual(sc.steps[1].response.forks.map((f: any) => f.card.order), [0]);
assert.equal(check(forkSchema, "cursor", "c0"), false);
});
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>();

View file

@ -393,6 +393,16 @@ 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)]
q0 = fcard(0)
ORDER0 = [fentry(fcard(3)), fentry(fcard(2)), fentry(fcard(1)), fentry(q0)] # a Mind whose oldest card has order 0
q4b = dict(fcard(4), fork_id="gamecult-chat:question:q4b") # a second card on order 4 in the same Mind
def order_shared(entries):
seen, out = {}, []
for e in entries:
c = e["card"]
if c["order"] in seen: out.append(f"order {c['order']} is shared by {seen[c['order']]} and {c['fork_id']}")
else: seen[c["order"]] = c["fork_id"]
return out
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"])
@ -409,8 +419,11 @@ LISTS = [
{"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": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]},
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
]
DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50))
assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"]
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"),
@ -601,7 +614,9 @@ fork = {
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]],
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]]
+ [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST),
messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])],
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
"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],

View file

@ -532,6 +532,20 @@ namespace GameCult.Networking.Tests
if ((record["counted_answer_id"] is null ? null : S(record["counted_answer_id"])) != counted) yield return "counted_answer_id is not derived";
}
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
private static List<string> OrderErrors(IEnumerable<JsonNode?> cards)
{
var holder = new Dictionary<long, string>();
var errors = new List<string>();
foreach (var c in cards)
{
var order = c!["order"]!.GetValue<long>();
if (holder.TryGetValue(order, out var first)) errors.Add($"order {order} is shared by {first} and {S(c["fork_id"])}");
else holder[order] = S(c["fork_id"]);
}
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.
private static JsonNode JudgeList(JsonArray entries, long ordinal, JsonNode request)
{
@ -636,7 +650,9 @@ namespace GameCult.Networking.Tests
}
foreach (var v in t["listInvalid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "list", v!["message"]), Is.Not.Empty, $"{S(v["name"])}: {S(v["why"])}");
var shared = OrderErrors(v!["message"]!["forks"]!.AsArray().Select(f => f!["card"]));
Assert.That(Errors(ForkSchema, "list", v["message"]).Count > 0 || shared.Count > 0, Is.True, $"{S(v["name"])}: {S(v["why"])}");
if (v["refusal"] is not null) Assert.That(shared, Is.EqualTo(new[] { S(v["refusal"]) }), S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
}
@ -649,6 +665,7 @@ namespace GameCult.Networking.Tests
var name = S(sc!["name"]);
var entries = sc["entries"]!.AsArray();
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
Assert.That(OrderErrors(entries.Select(e => e!["card"])), Is.Empty, name);
JsonNode? previous = null;
foreach (var step in sc["steps"]!.AsArray())
{
@ -664,6 +681,17 @@ namespace GameCult.Networking.Tests
}
}
[Test]
public void ForkTransport_OrderZero_IsLegal_NoCursorNamesIt_AndThePageEndingAtOrderOneHandsOnToIt()
{
var sc = Fork["transport"]!["listScenarios"]!.AsArray().Single(x => S(x!["name"]) == "order_zero_is_legal_but_no_cursor_names_it")!;
var steps = sc["steps"]!.AsArray();
Assert.That(S(steps[0]!["response"]!["next_cursor"]), Is.EqualTo("c1"));
Assert.That(steps[1]!["response"]!["next_cursor"], Is.Null);
Assert.That(steps[1]!["response"]!["forks"]!.AsArray().Select(f => f!["card"]!["order"]!.GetValue<long>()), Is.EqualTo(new[] { 0L }));
Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create("c0")), Is.Not.Empty);
}
[Test]
public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing()
{